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.
This commit is contained in:
2026-08-28 16:12:17 +00:00
parent 2060dc815c
commit 6910ccad73
11 changed files with 213 additions and 81 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
- Maintaining and owning the app's own `Data` object is preferable; Kanta never copies this, only edits in place
- Note: besides opening it every access is immediate direct variable access: no `await`, no locks, no delays
- **fastapi-vue** — template glue for serving/building the Vue frontend; keep its integration points (`Frontend`, build hook) intact.
- **markdown-it-py** — Markdown rendering with `html=True` raw passthrough; mdit-py-plugins for footnote/deflist/tasklists/attrs; **Pygments** for server-side code highlighting (`nowrap` spans, styled by `frontend/src/assets/pygments.css` which maps token classes 1:1 onto the `--code-*` variables; light/dark palette sets live in `pagerite.css` and resolve via `light-dark()` from the theme's `color-scheme` — themes pick a set, not individual colors).
- **markdown-it-py** — Markdown rendering with `html=True` raw passthrough; mdit-py-plugins for footnote/deflist/tasklists/attrs; in-body h1/h2 headings get auto slug ids + self-links when the body has 3+ of them (`python-slugify`, mirroring `slugify.js`); **Pygments** for server-side code highlighting (`nowrap` spans, styled by `frontend/src/assets/pygments.css` which maps token classes 1:1 onto the `--code-*` variables; light/dark palette sets live in `pagerite.css` and resolve via `light-dark()` from the theme's `color-scheme` — themes pick a set, not individual colors).
## Conventions
+3 -3
View File
@@ -18,15 +18,15 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full
## `markdown.py`
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages).
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three in-body h1/h2 headings, 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; 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).
`render()` returns a `Rendered(html, multicol)`: the body segmented for the column layout — h1/h2 headings, `.wide` blocks and margin-breakout blocks (`.margin`, `::: aside`) stand bare, the runs between them become `<div class="colseg">` (plus `.cols` on segments with enough text, `::: nocols` opting out), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation).
`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, `::: nocols` opting out), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation).
## `views.py`
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`. If the markdown contains its own h1, the page title is NOT rendered as an additional h1 (it still 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`. 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).
+1 -1
View File
@@ -45,7 +45,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
## Editing
- Editing happens **in place**, in two modes opened by two pens:
- **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) straight into the visible article's heading and body.
- **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 🖊️ on the banner opens a panel with the site **brand** (applied to the header live), a **theme** selector (swapping the theme stylesheet 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.)
- **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.
+1 -1
View File
@@ -8,7 +8,7 @@ Vue editor app entry, mounts the tabbed `EditorShell`. See `docs/editing.md` for
## `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, 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, 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`.
+16 -21
View File
@@ -83,7 +83,7 @@ function requestRender() {
// No debounce: server-side rendering is fast enough per keystroke.
if (!view) return
dirty.value = true
send({ type: 'render', path: path.value, markdown: view.state.doc.toString() })
send({ type: 'render', path: path.value, title: title.value, markdown: view.state.doc.toString() })
}
function save() {
@@ -240,29 +240,24 @@ function runScripts(root) {
}
}
function previewIntoArticle(html, hasH1, multicol) {
function previewIntoArticle(html, multicol) {
const article = document.querySelector('#main article')
if (!article) return
// The server render owns the column layout: .multicol on the article,
// the segmented .colseg/.cols structure inside .body. Both arrive with
// the preview and must stay in sync as edits cross the thresholds.
// The server render owns the article completely — the injected title h1,
// the column layout (.multicol on the article, the .colseg/.cols
// segments) — so the whole article content swaps as one. Only the edit
// pen and the category cards survive: detach them before innerHTML wipes
// them. pagerite.js re-places the pen into the first visible h1 on
// pagerite:preview.
article.classList.toggle('multicol', multicol)
const h1 = article.querySelector('h1')
const body = article.querySelector('.body')
// The edit pen may be tucked inside an h1 (title or markdown-owned);
// detach it before textContent/innerHTML wipes destroy the element.
// pagerite.js re-places it into the first visible h1 on pagerite:preview.
const pen = article.querySelector('button.edit-link')
if (pen && (h1?.contains(pen) || body?.contains(pen))) article.prepend(pen)
if (h1) {
h1.style.display = hasH1 ? 'none' : ''
h1.textContent = title.value
}
if (body) {
body.innerHTML = html
runScripts(body)
dispatchEvent(new CustomEvent('pagerite:preview'))
}
if (pen) pen.remove()
const cards = article.querySelector(':scope > .cards')
if (cards) cards.remove()
article.innerHTML = html
if (cards) article.append(cards)
runScripts(article)
dispatchEvent(new CustomEvent('pagerite:preview'))
}
function onMessage(ev) {
@@ -274,7 +269,7 @@ function onMessage(ev) {
requestRender()
dirty.value = false // just loaded from the server, nothing unsaved
} else if (msg.type === 'html' && msg.path === path.value) {
previewIntoArticle(msg.html, msg.has_h1, msg.multicol)
previewIntoArticle(msg.html, msg.multicol)
} else if (msg.type === 'saved') {
saveError.value = ''
pendingSave = null
+32 -21
View File
@@ -712,19 +712,18 @@ article.multicol {
/* The side zone (not on phones): lane content indents 16rem; margin
boxes ({.margin} / ::: margin blocks, ::: aside, {.margin} figures)
float at the article's left edge — the same region the nav sidebar
overlays. Scoped to direct .body children (the backend render keeps
overlays. Scoped to direct article children (the backend render keeps
margin blocks out of the column segments); nested ones keep the
in-column float fallback. */
.multicol .body>.colseg,
.multicol .body>h1,
.multicol .body>h2,
article.multicol>h1 {
.multicol article>.colseg,
.multicol article>h1,
.multicol article>h2 {
margin-left: 16rem;
}
.multicol .body>.margin,
.multicol .body>.aside,
.multicol .body>figure:has(.margin) {
.multicol article>.margin,
.multicol article>.aside,
.multicol article>figure:has(.margin) {
float: left;
clear: left;
width: 14rem;
@@ -734,9 +733,9 @@ article.multicol {
/* Wide separators start below any margin box — their bleed must not
wrap around it. */
.multicol .body>figure:has(.wide),
.multicol .body>div.wide,
.multicol .body>pre.wide {
.multicol article>figure:has(.wide),
.multicol article>div.wide,
.multicol article>pre.wide {
clear: left;
}
}
@@ -768,16 +767,15 @@ article.multicol {
max-width: 86rem;
}
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>.colseg,
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>h1,
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>h2,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>h1 {
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>.colseg,
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>h1,
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>h2 {
margin-left: 0;
}
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>.margin,
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>.aside,
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>figure:has(.margin) {
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>.margin,
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>.aside,
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>figure:has(.margin) {
float: left;
clear: left;
width: 12rem;
@@ -828,6 +826,19 @@ article h2 {
margin: 2.2rem 0 0.6rem;
}
/* Heading self-links (section anchors) look exactly like plain heading
text — click sets the hash, right-click copies the link. */
article :is(h1, h2) a.anchor,
article :is(h1, h2) a.anchor:hover {
color: inherit;
font-weight: inherit;
}
/* Anchored headings clear the fixed/sticky top nav when scrolled to. */
article :is(h1, h2)[id] {
scroll-margin-top: 4rem;
}
article a {
color: var(--link);
font-weight: 500;
@@ -1172,9 +1183,9 @@ figure:has(.margin) {
editing the docked panel reshapes the gutters — in both they stay
plain floats). */
@media (min-width: 104rem) {
body:not(.editing):not(:has(.multicol)) .body>.margin,
body:not(.editing):not(:has(.multicol)) .body>.aside,
body:not(.editing):not(:has(.multicol)) .body>figure:has(.margin) {
body:not(.editing):not(:has(.multicol)) article>.margin,
body:not(.editing):not(:has(.multicol)) article>.aside,
body:not(.editing):not(:has(.multicol)) article>figure:has(.margin) {
float: left;
clear: left;
width: 12rem;
+44 -1
View File
@@ -388,6 +388,36 @@ import "overlayscrollbars/overlayscrollbars.css";
}, { passive: true });
}
// --- Section hash -------------------------------------------------------
// Reflect the section being read in the location hash: the last h1/h2
// above the middle of the viewport is current, even when already
// scrolled out of view. Above the first tagged heading the hash is
// removed — including at the very top of the document, where an early
// heading may sit in the top half. Pages too short to scroll never get
// a hash, and articles with few headings have no ids at all (the
// backend only anchors h1/h2 in bodies of 3+ such headings).
// replaceState keeps this out of history; fetch-navigation already
// handles anchor scrolling itself.
let hashQueued = false;
addEventListener("scroll", () => {
if (hashQueued) return;
hashQueued = true;
requestAnimationFrame(() => {
hashQueued = false;
const mid = innerHeight / 2;
let current = null;
for (const h of document.querySelectorAll("article :is(h1, h2)[id]")) {
if (h.getBoundingClientRect().top < mid) current = h;
else break;
}
const scrollable = document.documentElement.scrollHeight > innerHeight;
const want = !scrollable || scrollY === 0 || !current ? "" : `#${current.id}`;
if (want !== location.hash) {
history.replaceState(null, "", want || location.pathname + location.search);
}
});
}, { passive: true });
// --- Analytics pings ---------------------------------------------------
// Fire-and-forget POSTs to /_a with the fields as query parameters (a
// beacon can carry no body, and query args show in server logs next to
@@ -660,7 +690,12 @@ import "overlayscrollbars/overlayscrollbars.css";
}
currentPath = new URL(finalUrl, location.href).pathname;
if (push) history.pushState(null, "", finalUrl);
scrollTo(0, 0);
// Cross-page anchor links scroll to the section after the swap (the
// browser only does this itself on full page loads).
const hash = new URL(finalUrl, location.href).hash;
const target = hash && document.getElementById(hash.slice(1));
if (target) target.scrollIntoView();
else scrollTo(0, 0);
return true;
}
@@ -710,6 +745,14 @@ import "overlayscrollbars/overlayscrollbars.css";
}
// Same-page anchor links (footnotes etc.): let the browser handle them
if (url.pathname === location.pathname && url.hash) return;
// The first in-body h1 self-links with href="": scroll to the top and
// drop the section hash — not a navigation, no analytics ping.
if (a.getAttribute("href") === "" && url.pathname === location.pathname) {
ev.preventDefault();
history.replaceState(null, "", location.pathname + location.search);
scrollTo({ top: 0, behavior: reduceMotion.matches ? "instant" : "smooth" });
return;
}
// Machinery and auth endpoints are never fetch-navigated, except the
// public analytics viewer page at /_a.
if ((url.pathname.startsWith("/_") && url.pathname !== "/_a")
+6 -4
View File
@@ -56,7 +56,7 @@ from pagerite.data import (
resolve,
sorted_nodes,
)
from pagerite.markdown import has_h1, render, toggle_task
from pagerite.markdown import render, toggle_task
# Site identity: the hostname comes from the CLI (first positional argument,
# exported as PAGERITE_HOSTNAME) and names the per-site data directory
@@ -1122,16 +1122,18 @@ async def editor_ws(ws: WebSocket) -> None:
path,
node.created if node else None,
node.modified if node else None,
# The title is injected as h1 when the markdown has
# none; the editor's title field edits live-preview.
title=msg.get("title") or (node.title if node else ""),
)
await ws.send_json({
"type": "html",
"path": path,
"html": rendered.html,
# Column-layout flags: the preview toggles the
# Column-layout flag: the preview toggles the
# article's .multicol class and swaps in the
# segmented (.colseg/.cols) body html.
# segmented (.colseg/.cols) article html.
"multicol": rendered.multicol,
"has_h1": has_h1(markdown),
})
case "save":
move_from = (msg.get("move_from") or path).strip("/")
+100 -18
View File
@@ -56,6 +56,7 @@ from typing import NamedTuple
from markdown_it import MarkdownIt
from markdown_it.common.utils import escapeHtml
from markdown_it.renderer import RendererHTML
from markdown_it.token import Token
from mdit_py_plugins.admon import admon_plugin
from mdit_py_plugins.attrs import attrs_plugin
from mdit_py_plugins.attrs.parse import ParseError, parse as parse_attrs
@@ -70,6 +71,7 @@ from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound
from slugify import slugify
# Styles in /_assets/pygments-*.css match this formatter (regenerate:
# HtmlFormatter(style="github-dark").get_style_defs("pre code"))
@@ -110,11 +112,11 @@ def _fence_rule(
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))
highlighted = _highlight(token.content, lang, "") or escapeHtml(token.content)
code_class = f' class="{options.langPrefix}{lang}"' if lang else ""
return (f"<pre{self.renderAttrs(token)}><code{code_class}>"
f"{highlighted}</code></pre>\n")
return (
f"<pre{self.renderAttrs(token)}><code{code_class}>{highlighted}</code></pre>\n"
)
def _image_rule(
@@ -157,8 +159,10 @@ def _unwrap_lone_figures(state) -> None:
continue
[child] = token.children if len(token.children) == 1 else [None]
if child and child.type == "image":
if (tokens[i - 1].type == "paragraph_open"
and tokens[i + 1].type == "paragraph_close"):
if (
tokens[i - 1].type == "paragraph_open"
and tokens[i + 1].type == "paragraph_close"
):
# A lone image becomes a <figure> (see _image_rule); block
# attrs on the paragraph (e.g. a trailing {.wide} line) move
# onto the image so they survive the unwrap.
@@ -269,8 +273,11 @@ def _block_attrs(state) -> None:
if token.type != "inline" or not token.children:
continue
text = token.children[-1]
if (text.type != "text" or not text.content.startswith("{")
or not text.content.endswith("}")):
if (
text.type != "text"
or not text.content.startswith("{")
or not text.content.endswith("}")
):
continue
try:
_, attrs = parse_attrs(text.content.strip())
@@ -292,9 +299,14 @@ def _block_attrs(state) -> None:
if target.hidden:
pass
elif standalone:
if (j != own and target.level == tokens[own].level
and (target.nesting == 1
or target.type in ("fence", "code_block", "hr"))):
if (
j != own
and target.level == tokens[own].level
and (
target.nesting == 1
or target.type in ("fence", "code_block", "hr")
)
):
break
elif target.nesting == 1:
break
@@ -310,6 +322,70 @@ def _block_attrs(state) -> None:
del token.children[-2:]
#: Minimum number of in-body h1/h2 headings for section anchors to be
#: useful — shorter articles get no ids/self-links at all.
ANCHOR_MIN_HEADINGS = 3
def _heading_ids(state) -> None:
"""Anchor the in-body h1/h2 headings of long-enough articles.
The markdown body's own h1 and h2 headings get a slug id and their
text is wrapped in a self-link (``<a class="anchor" href="#id">``) so
section links are copyable by click or right-click — but only when the
body has at least ANCHOR_MIN_HEADINGS of them; shorter articles stay
anchor-free. The FIRST h1 is the article title: like the implicit
page-title h1 it gets no id, does not count toward the threshold, and
its self-link is ``href=""`` (back to the top of the page). An
author-set `{#id}` always wins; auto ids slugify the heading text
(python-slugify, mirroring the editor's slugify.js) and dedupe with
-2/-3 suffixes per render. Headings that already contain a link are
left unwrapped. Deeper headings (h3+) are never navigable.
"""
tokens = state.tokens
def wrap(i: int, token, href: str) -> None:
inline = tokens[i + 1]
if not inline.children or any(c.type == "link_open" for c in inline.children):
return
anchor = Token("link_open", "a", 1)
anchor.attrs = {"href": href, "class": "anchor"}
inline.children = [anchor, *inline.children, Token("link_close", "a", -1)]
# The first in-body h1 is the title: href="" self-link, never an id.
first_h1 = next(
(i for i, t in enumerate(tokens) if t.type == "heading_open" and t.tag == "h1"),
None,
)
if first_h1 is not None:
wrap(first_h1, tokens[first_h1], "")
heads = [
(i, token)
for i, token in enumerate(tokens)
if token.type == "heading_open" and token.tag in ("h1", "h2") and i != first_h1
]
if len(heads) < ANCHOR_MIN_HEADINGS:
return
seen: set[str] = set()
for i, token in heads:
inline = tokens[i + 1]
hid = token.attrGet("id")
if not isinstance(hid, str) or not hid:
# Slug the visible text, not the raw markdown (`## [a](url)`).
text = "".join(
c.content for c in inline.children if c.type in ("text", "code_inline")
)
base = slugify(text) or "section"
hid, n = base, 2
while hid in seen:
hid = f"{base}-{n}"
n += 1
token.attrSet("id", hid)
seen.add(hid)
wrap(i, token, f"#{hid}")
md = (
MarkdownIt(
"default",
@@ -340,6 +416,7 @@ md.core.ruler.push("container_attrs", _container_attrs)
md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
md.core.ruler.push("shorten_autolinks", _shorten_autolinks)
md.core.ruler.push("heading_ids", _heading_ids)
# Text-length thresholds (visible characters, code blocks excluded) for the
@@ -353,7 +430,7 @@ _TAG_RE = re.compile(r"<[^>]+>")
# 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
# article's left (they must be direct .body children for that — the zone
# article's left (they must be direct article children for that — the zone
# rules key off it — never inside a column).
_WIDE = "wide"
_BREAKOUT = ("margin", "aside")
@@ -419,9 +496,14 @@ def render(
page_path: str = "",
created: datetime | None = None,
modified: datetime | None = None,
title: str | None = None,
) -> Rendered:
"""Render Markdown text to the article body's HTML and layout flags.
``title`` injects a ``# {title}`` line at the top when the markdown has
no h1 of its own, so the implicit page title goes through the exact
same pipeline as an explicit one (first-h1 anchor treatment included).
The top-level blocks are grouped into column segments: boundary blocks
(h1/h2 headings, .wide, margin-breakout blocks — see _is_boundary) are
rendered bare, the runs between them wrapped in <div class="colseg">.
@@ -436,6 +518,8 @@ def render(
right after the article's h1.
"""
env = {"page_path": page_path}
if title and not has_h1(text):
text = f"# {title}\n\n{text}"
blocks = _top_level_blocks(md.parse(text, env))
# Group consecutive non-boundary blocks into segments (is_segment,
# flat tokens); boundary blocks stand on their own between them.
@@ -460,9 +544,7 @@ def render(
parts.append(html)
continue
nocols = any(
"nocols" in _classes(t)
for t in group
if t.type == "container_block_open"
"nocols" in _classes(t) for t in group if t.type == "container_block_open"
)
cols = " cols" if text_len > COLS_TEXT and not nocols else ""
parts.append(f'<div class="colseg{cols}">{html}</div>')
@@ -485,9 +567,9 @@ def _dateline(created: datetime, modified: datetime | None) -> str:
def has_h1(text: str) -> bool:
"""True if the Markdown source itself contains an h1 heading.
When it does, the article owns its heading and the page title is not
rendered as an additional h1 (the title is still used for the document
<title> and navigation labels).
When it does, the article owns its heading and render(title=...) does
not inject the page title as an h1 (the title is still used for the
document <title> and navigation labels).
"""
return any(t.type == "heading_open" and t.tag == "h1" for t in md.parse(text))
+8 -10
View File
@@ -24,7 +24,7 @@ import re
from html5tagger import HTML, Document, E, Template
from pagerite.data import Node, prettify, resolve, sorted_nodes
from pagerite.markdown import has_h1, render
from pagerite.markdown import render
SITE_NAME = "Pagerite"
BUILD = Path(__file__).with_name("frontend-build")
@@ -520,18 +520,16 @@ def page_content(menu: dict[str, Node], path: str) -> HTML:
after the markdown content.
"""
node = resolve(menu, path)[-1]
rendered = render(node.content or "", path, node.created, node.modified)
# The title is injected into the markdown (as # title when it has no
# h1 of its own), so title and content render as one article.
rendered = render(node.content or "", path, node.created, node.modified, title=node.title)
# Long articles get .multicol: the article column cap lifts (see the
# #content grid in pagerite.css) and the body's .cols segments lay out
# in at most two columns. The .body html is already segmented by
# render() — the whole layout is driven by these classes.
# #content grid in pagerite.css) and the .cols segments lay out in at
# most two columns. The html is already segmented by render() — the
# whole layout is driven by these classes.
doc = E.article(class_="multicol") if rendered.multicol else E.article
with doc:
# An h1 in the markdown owns the article heading; the title is
# only rendered as h1 when the markdown has none of its own.
if not has_h1(node.content or ""):
doc.h1(node.title)
doc.div(HTML(rendered.html), class_="body")
doc(HTML(rendered.html))
_cards(doc, menu, node, path)
return HTML(str(doc))
+1
View File
@@ -26,6 +26,7 @@ dependencies = [
"maxminddb>=3.1.1",
"mdit-py-plugins>=0.6.1",
"pygments>=2.20.0",
"python-slugify>=8.0.4",
"ua-parser>=1.0.2",
"zstandard>=0.25.0",
]