From 3518ac9ac7cc719be3dbe58d9494ffc8551fd7d3 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 27 Aug 2026 01:27:49 +0000 Subject: [PATCH] Fixes to markdown extensions/styling. Sort crawlers most recent first. --- docs/analytics.md | 2 +- frontend/src/analytics/format.js | 6 +++--- frontend/src/assets/pagerite.css | 14 +++++++------- frontend/src/pagerite.js | 23 ++++++++++++++++++++--- pagerite/markdown.py | 26 ++++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 14 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index aee68fc..f4fedd6 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -100,7 +100,7 @@ falsy values are omitted): country/city are filled in asynchronously, just like for real visits. In the analytics viewer, crawler hits are grouped by client hash and shown as a trail of internal pages that crawler visited; the crawler table lists - the most active crawlers first rather than the most recent hits. + the most recent crawler first, with the most active as a tie-breaker. - **Abuse (scanner) hits**: a 404 for a telltale path — any URL segment starting with a dot (`/.env`, `/.git/config`) or ending in `.php` — classifies the source IP as abuse immediately, and ten plain 404s from one diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js index 5a0f846..c620455 100644 --- a/frontend/src/analytics/format.js +++ b/frontend/src/analytics/format.js @@ -349,8 +349,8 @@ export function mainDomain(host, limit = 24) { /** * Group raw crawler hits by client hash and format each group as a row showing - * every internal page that crawler visited. Rows are sorted by total hits, - * most active crawler first, rather than by most recent hit. + * every internal page that crawler visited. Rows are sorted by most recent hit + * first, with total hits as a tie-breaker. * ``clients`` maps client hashes to client records. */ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now()) { @@ -380,7 +380,7 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now()) return n } return [...groups.values()] - .sort((a, b) => totalHits(b) - totalHits(a) || b.lastStart - a.lastStart) + .sort((a, b) => b.lastStart - a.lastStart || totalHits(b) - totalHits(a)) .slice(0, 10) .map((g) => { const client = g.client || {} diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index 3da56f5..7a1d3f1 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -928,10 +928,10 @@ figure:has(img[width]) { later rules win at equal specificity. The analytics dashboard uses the same breakout directly on its container (div.wide — it is the page's whole content, not a figure), and code blocks via a trailing {.wide} - line (fence attrs land on , hence pre:has(.wide)). */ + line (fence block attrs land on
 itself). */
 figure:has(.wide),
 div.wide,
-pre:has(.wide) {
+pre.wide {
   width: 100vw;
   max-width: none;
   margin-inline: calc(50% - 50vw);
@@ -939,7 +939,7 @@ pre:has(.wide) {
 
 /* Full bleed means edge to edge — no rounded corners. */
 figure:has(.wide) img,
-pre:has(.wide) {
+pre.wide {
   border-radius: 0;
 }
 
@@ -948,7 +948,7 @@ pre:has(.wide) {
    1fr + 4fr grid, i.e. 20vw — plus main's padding, and spans on to the
    right viewport edge. */
 body:has(.multicol) figure:has(.wide),
-body:has(.multicol) pre:has(.wide) {
+body:has(.multicol) pre.wide {
   margin-inline: calc(-20vw - 1.25rem) 0;
 }
 
@@ -956,7 +956,7 @@ body:has(.multicol) pre:has(.wide) {
    window keeps its overlay scrollbars while editing, so — unlike a classic
    scrollbar — they take no layout space and the vw math stays exact. */
 body.editing figure:has(.wide),
-body.editing pre:has(.wide) {
+body.editing pre.wide {
   width: calc(100vw - var(--editor-w));
   margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
 }
@@ -964,7 +964,7 @@ body.editing pre:has(.wide) {
 /* Editing + multicol: the left gutter is 1/5 of the space right of the
    editor, and the bleed also crosses main's 1.25rem left padding. */
 body.editing:has(.multicol) figure:has(.wide),
-body.editing:has(.multicol) pre:has(.wide) {
+body.editing:has(.multicol) pre.wide {
   margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0;
 }
 
@@ -977,7 +977,7 @@ body.editing:has(.multicol) pre:has(.wide) {
    where the editing rules above apply instead. */
 @media (max-width: 102rem) {
   body:has(#sidebar):not(.editing) figure:has(.wide),
-  body:has(#sidebar):not(.editing) pre:has(.wide) {
+  body:has(#sidebar):not(.editing) pre.wide {
     margin-inline: -13.25rem 0;
   }
 }
diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js
index 714a6dc..2169b70 100644
--- a/frontend/src/pagerite.js
+++ b/frontend/src/pagerite.js
@@ -235,9 +235,26 @@ import "overlayscrollbars/overlayscrollbars.css";
       btn.textContent = "copy";
       btn.addEventListener("click", async () => {
         const code = pre.querySelector("code");
-        await navigator.clipboard.writeText(
-          (code || pre).textContent.replace(/\n$/, ""),
-        );
+        const text = (code || pre).textContent.replace(/\n$/, "");
+        // navigator.clipboard exists only in secure contexts (https or
+        // localhost); viewing over plain http needs the textarea fallback.
+        try {
+          if (navigator.clipboard) {
+            await navigator.clipboard.writeText(text);
+          } else {
+            const ta = document.createElement("textarea");
+            ta.value = text;
+            ta.style.cssText = "position:fixed;opacity:0";
+            document.body.append(ta);
+            ta.select();
+            document.execCommand("copy");
+            ta.remove();
+          }
+        } catch {
+          btn.textContent = "failed";
+          setTimeout(() => (btn.textContent = "copy"), 1500);
+          return;
+        }
         btn.textContent = "copied";
         btn.classList.add("copied");
         setTimeout(() => {
diff --git a/pagerite/markdown.py b/pagerite/markdown.py
index d6a18c2..f88a583 100644
--- a/pagerite/markdown.py
+++ b/pagerite/markdown.py
@@ -78,6 +78,31 @@ def _highlight(text: str, lang: str, _attrs: str) -> str:
     return highlight(text, lexer, _formatter)
 
 
+def _fence_rule(
+    self: RendererHTML,
+    tokens,
+    idx: int,
+    options,
+    env: dict,
+) -> str:
+    """Render a fenced code block.
+
+    Like the default fence rule, but block attributes (a trailing `{...}`
+    line, applied to the fence token by _block_attrs) go on the 
 — the
+    block element — instead of the , which keeps only the language
+    class. This is what makes e.g. `{.wide}` or `{style="..."}` after a
+    code fence style the block itself.
+    """
+    token = tokens[idx]
+    info = token.info.strip() if token.info else ""
+    lang = info.split(maxsplit=1)[0] if info else ""
+    highlighted = (_highlight(token.content, lang, "")
+                   or escapeHtml(token.content))
+    code_class = f' class="{options.langPrefix}{lang}"' if lang else ""
+    return (f""
+            f"{highlighted}
\n") + + def _image_rule( self: RendererHTML, tokens, @@ -286,6 +311,7 @@ md = ( .use(superscript_plugin) ) md.add_render_rule("image", _image_rule) +md.add_render_rule("fence", _fence_rule) # GFM alerts (`> [!NOTE]` etc.), built into markdown-it-py's blockquote rule. md.options["alerts"] = True # Block attrs must be stripped before the typographer curlifies their quotes.