Compare commits

..
3 Commits
Author SHA1 Message Date
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 f708e1dbca Avoid breaks inside lists. 2026-08-27 17:24:08 +00:00
8 changed files with 409 additions and 171 deletions
+2
View File
@@ -18,6 +18,8 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages).
`render()` returns a `Rendered(html, multicol)`: the body segmented for the column layout — h1/h2 headings, `.wide` blocks and margin-breakout blocks (`.margin`, `::: aside`) stand bare, the runs between them become `<div class="colseg">` (plus `.cols` on segments with enough text, `::: nocols` opting out), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation).
## `views.py`
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.
+3 -3
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.).
- **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 (leaning into the empty right gutter on wide single-column pages) and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `![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), `{.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.
- 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.
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `![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.
## Page structure and navigation
@@ -34,7 +34,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
## Reading experience
- The article column is sized by the **viewport, never by content**: a symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it; wide screens get columns inside long articles without changing the article's width. Columns are decided client-side (pagerite.js): the body splits into segments at h1/h2 headings and `.wide` elements (full-width separators, never inside columns), and a segment gets columns only when it holds enough text — code blocks are excluded from that measure, and a `::: nocols` container opts its whole section out.
- 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 below ~110rem (where the sidebar gets its own 12rem track) the track is the left lane instead: no in-article zone, the text lane runs fluid up to 86rem, and the boxes fall into the track, 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 with `.cols` — code blocks are excluded from that measure, and a `::: nocols` container opts its whole section out. On wide single-column pages (≥104rem), margin boxes lean into the vacant left gutter as well.
- 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
+6 -2
View File
@@ -240,9 +240,13 @@ function runScripts(root) {
}
}
function previewIntoArticle(html, hasH1) {
function previewIntoArticle(html, hasH1, 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.
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);
@@ -270,7 +274,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)
previewIntoArticle(msg.html, msg.has_h1, msg.multicol)
} else if (msg.type === 'saved') {
saveError.value = ''
pendingSave = null
+233 -81
View File
@@ -275,14 +275,25 @@ body {
grid-template-columns: minmax(0, 1fr) minmax(0, 78rem) minmax(0, 1fr);
}
/* Long articles (.multicol is added by pagerite.js based on content length)
lift the 78rem cap and scrap the right gutter: a 1fr left gutter (which
holds the overlaying sidebar) and the article taking all the rest, out
to the right viewport edge. The column count follows the width (see the
`columns: 30rem` rule below). The .wide breakout is re-anchored to the
left gutter below (the article is no longer viewport-centered). */
/* Long articles (.multicol comes from the backend render, based on
content length — code excluded) lift the 78rem cap: main takes the full
width and the article composes itself inside it — fluid, bounded text
lanes, centered, with the surplus left vacant (see the article layout
rules below). The left track — main always sits in column 2 — collapses
to zero when the page has no sidebar; the sidebar then simply overlays
the vacant zone, as it does on single-column pages. */
body:has(.multicol) #content {
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr);
grid-template-columns: 0 minmax(0, 1fr);
}
/* Only when the vacant zone cannot hold the sidebar does it get its own
12rem track — the same compromise single-column pages make below
102rem. Above that, a sidebar's presence changes nothing about the
article. */
@media (max-width: 110rem) {
body:has(#sidebar):has(.multicol):not(.editing) #content {
grid-template-columns: 12rem minmax(0, 1fr);
}
}
body.editing #content {
@@ -420,6 +431,11 @@ main {
/* No top padding: a leading wide image sits flush under the banner, and
text-first pages get their spacing from the h1's top margin instead. */
padding: 0 1.25rem 3rem;
/* The layout container for the article composition: the multicol stage
(lane count), the margin fall and the .wide bleed respond to the
actual available width here — editor inset included — via container
queries and cqw units. */
container-type: inline-size;
}
article h1,
@@ -561,18 +577,107 @@ article dd {
hyphens: auto;
}
/* Multi-column reading, but only for long articles (pagerite.js adds
.multicol based on content length — code blocks excluded — and splits
the body into .colseg segments separated by full-width h2s and .wide
elements; only segments with enough text get .cols, and a ::: nocols
container opts its section out). No fixed breakpoint: `columns: 30rem` lets CSS fit as
many columns of at least 30rem as the article's current width allows —
since .multicol also uncaps the article width (see #content above), a
wider window simply yields more columns. */
.multicol .colseg.cols {
columns: 30rem;
/* The long-article composition (.multicol): a fluid but bounded text
lane with a 16rem side zone at the article's left, centered in main —
surplus width becomes vacant space, never endless text. (The backend
render splits the body into .colseg segments separated by full-width
h2s and .wide elements, tags text-heavy segments .cols — a ::: nocols
container opts its section out — and flags the article .multicol; CSS
owns the geometry.) Technical content wants a wide lane: up to 42rem
single, or 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 sidebar overlays — is a margin indent on the lane
content; margin boxes float into it, and the text never moves. */
article.multicol {
margin-inline: auto;
max-width: 58rem; /* 42rem lane + 16rem zone */
}
@container (min-width: 45rem) {
/* 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
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 {
margin-left: 16rem;
}
.multicol .body>.margin,
.multicol .body>.aside,
.multicol .body>figure:has(.margin) {
float: left;
clear: left;
width: 14rem;
max-width: none;
margin: 0.3rem 2rem 1rem 0;
}
/* 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 {
clear: left;
}
}
@container (min-width: 96rem) {
article.multicol {
max-width: 102rem;
}
/* Two fluid lanes (36rem minimum) beside the zone, up to the 102rem
cap — wider windows just add vacant space. */
.multicol .colseg.cols {
columns: 36rem 2;
column-gap: 3.5rem;
column-rule: 1px solid var(--line);
}
}
/* With a sidebar, below 110rem the sidebar gets its own 12rem track (see
#content). The track IS the left lane then: no in-article zone, the
text lane runs fluid (up to 86rem), and margin boxes fall all the way
left into the track — sliding under the translucent sticky nav, which
only ever occupies its top. (The track never exists once the container
reaches 96rem, so the two-lane rules above never meet it. Not below
48rem: there the sidebar becomes a link strip above the article and
there is no track to fall into.) */
@media (min-width: 48rem) and (max-width: 110rem) {
body:has(#sidebar):has(.multicol):not(.editing) 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 {
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) {
float: left;
clear: left;
width: 12rem;
max-width: none;
/* From the text's left edge to the page's left edge: half the
centering difference plus the track and main's padding. */
margin: 0.3rem 0 1rem calc(50% - 50cqw - 13.25rem);
}
}
/* A shrink-wrapped figure (explicit image width) centers in the plain
layout; inside a column the centering looks adrift — left-align.
Floated figures keep their own margins (the text gap). */
.multicol .colseg.cols figure:has(img[width]):not(:has(.left), :has(.right), :has(.margin)) {
margin-inline: 0;
}
.multicol .colseg {
@@ -594,6 +699,8 @@ article dd {
pre,
blockquote,
table,
ul,
ol,
dl,
.admonition,
.markdown-alert {
@@ -723,19 +830,20 @@ blockquote p + p {
--admonition-color: var(--accent3);
}
/* Asides (::: aside): a floated side box in the floated-figure idiom;
consecutive asides stack (clear: right). On wide single-column pages it
leans into the empty right gutter (below 104rem the gutter cannot hold
the box; multicol pages have no right gutter at all, and while editing
the docked panel reshapes the gutters — in all these it stays a plain
float). Headings already clear floats, so asides never bleed into the
next section. */
/* Side boxes: ::: aside is a muted floated box (consecutive asides stack
via clear: left); {.margin} / ::: margin is a plainer margin note, and
figures take {.margin} like {.left}. On multicol pages they float in
the composition's left side zone — or in the sidebar's track when the
layout reserves one (see the article section); on wide single-column
pages they lean into the left gutter (with the figure rules below);
otherwise they stay in-column left floats. Headings already clear
floats, so boxes never bleed into the next section. */
.aside {
float: right;
clear: right;
float: left;
clear: left;
width: 30%;
max-width: 20rem;
margin: 0.3rem 0 1rem 1.2rem;
margin: 0.3rem 1.2rem 1rem 0;
padding: 0.6rem 0.9rem;
font-size: 0.9rem;
color: var(--muted);
@@ -743,15 +851,19 @@ blockquote p + p {
border-radius: 0.3rem;
}
.aside> :last-child {
.aside> :last-child,
.margin> :last-child {
margin-bottom: 0;
}
@media (min-width: 104rem) {
body:not(.editing):not(:has(.multicol)) .aside {
width: 12rem;
margin-right: -13rem;
}
.margin {
float: left;
clear: left;
width: 30%;
max-width: 20rem;
margin: 0.3rem 1.2rem 1rem 0;
font-size: 0.9rem;
color: var(--muted);
}
pre {
@@ -915,6 +1027,31 @@ figure:has(img[width]) {
width: fit-content;
}
/* {.margin} figures float left like {.left} ones — until they fall into
the side zone (see the composition rules up in the article section). */
figure:has(.margin) {
float: left;
width: 30%;
max-width: 50%;
margin: 0.3rem 1em 1rem 0;
}
/* Wide single-column pages: margin boxes lean into the vacant left
gutter instead (below 104rem the gutter cannot hold the box, and while
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) {
float: left;
clear: left;
width: 12rem;
max-width: none;
margin: 0.3rem 0 1rem -13.25rem;
}
}
/* .wide is full bleed: edge to edge of the viewport (or of the space right
of the docked editor), while staying in flow so it keeps its vertical
space. The article column is centered in the available space, so negative
@@ -943,42 +1080,53 @@ pre.wide {
border-radius: 0;
}
/* .wide on multicol pages: the article is not viewport-centered (no right
gutter), so the bleed anchors at the left gutter — the 1fr share of the
1fr + 4fr grid, i.e. 20vw — plus main's padding, and spans on to the
right viewport edge. */
body:has(.multicol) figure:has(.wide),
body:has(.multicol) pre.wide {
margin-inline: calc(-20vw - 1.25rem) 0;
}
/* Editing: shrink the bleed to the space right of the docked editor. The
window keeps its overlay scrollbars while editing, so — unlike a classic
scrollbar — they take no layout space and the vw math stays exact. */
scrollbar — they take no layout space and the vw math stays exact.
Multicol pages measure the bleed from main instead (cqw rules below),
which insets for the editor automatically. */
body.editing figure:has(.wide),
body.editing pre.wide {
width: calc(100vw - var(--editor-w));
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
}
/* 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.wide {
margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0;
/* Narrow single-column pages with a sidebar: below 102rem the symmetric
gutters can no longer both hold the 12rem sidebar, so #content reserves
it with a fixed left track (see the matching media query below) and the
article always starts at 12rem (+ main's 1.25rem padding) — the bleed
margin is a plain constant. Scoped by :has(#sidebar) since the sidebar
element is omitted entirely on pages without sub-navigation, and
excluded while editing, where the editing rules above apply instead.
(Multicol pages use the same fixed track below 110rem — their rules
are below.) */
@media (max-width: 102rem) {
body:has(#sidebar):not(.editing):not(:has(.multicol)) figure:has(.wide),
body:has(#sidebar):not(.editing):not(:has(.multicol)) pre.wide {
margin-inline: -13.25rem 0;
}
}
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
longer both hold the 12rem sidebar, so #content reserves it with a fixed
left track (see the matching media query below) and the article always
starts at 12rem (+ main's 1.25rem padding) — the bleed margin is a plain
constant. Scoped by :has(#sidebar) since the sidebar element is omitted
entirely on pages without sub-navigation, and excluded while editing,
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.wide {
margin-inline: -13.25rem 0;
/* .wide on multicol pages: the same edge-to-edge bleed, but measured from
main (the container) instead of the viewport — cqw includes the editor
inset automatically, so no editing override is needed. The 2.5rem
covers main's side padding. */
body:has(.multicol) figure:has(.wide),
body:has(.multicol) div.wide,
body:has(.multicol) pre.wide {
width: calc(100cqw + 2.5rem);
margin-inline: calc(50% - 50cqw - 1.25rem);
}
/* Multicol with a sidebar track (≤110rem, see #content): main starts
12rem in, so the bleed extends left past the track to the true viewport
edge — sliding under the translucent sidebar. */
@media (max-width: 110rem) {
body:has(#sidebar):has(.multicol):not(.editing) figure:has(.wide),
body:has(#sidebar):has(.multicol):not(.editing) div.wide,
body:has(#sidebar):has(.multicol):not(.editing) pre.wide {
width: calc(100cqw + 14.5rem);
margin-inline: calc(50% - 50cqw - 13.25rem) 0;
}
}
@@ -1015,30 +1163,23 @@ article h2 {
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
longer both hold the 12rem sidebar, so reserve its space with a fixed
left track instead of letting it overlap the article. The article then
always starts at 12rem (+ main's 1.25rem padding), which the matching
.wide breakout rule in the images section relies on. Scoped by
:has(#sidebar) since the sidebar element is omitted entirely on pages
without sub-navigation. */
left track instead of letting it overlap the article (multicol pages use
this same track at every width — see the #content rules above). The
article then always starts at 12rem (+ main's 1.25rem padding), which
the matching .wide breakout rule in the images section relies on.
Scoped by :has(#sidebar) since the sidebar element is omitted entirely
on pages without sub-navigation. */
@media (max-width: 102rem) {
body:has(#sidebar):not(.editing) #content {
grid-template-columns: 12rem minmax(0, 78rem) minmax(0, 1fr);
}
/* Long articles stay fluid here too: same 12rem reservation for the
sidebar, then the article takes everything to the right viewport
edge. The article's left edge stays at 12rem either way, so the
constant .wide breakout margin remains correct. */
body:has(#sidebar):has(.multicol):not(.editing) #content {
grid-template-columns: 12rem minmax(0, 1fr);
}
}
/* Phones and other narrow viewports: single-column layout with the
sidebar lifted above the article as a wrapping link strip, and no
floated figures — .left/.right fall back to plain centered figures
(explicit img widths still shrink-wrap), while .wide keeps its full
viewport bleed. */
floats at all — .left/.right/.margin figures fall back to plain
centered figures (explicit img widths still shrink-wrap), margin boxes
go full width, while .wide keeps its full viewport bleed. */
@media (max-width: 48rem) {
/* Nav type shrinks fluidly as space runs out. The nav font-size is
@@ -1103,24 +1244,35 @@ article h2 {
}
figure:has(.right),
figure:has(.left) {
figure:has(.left),
figure:has(.margin) {
float: none;
width: 100%;
max-width: none;
margin: 0 auto 1.5rem;
}
/* Margin boxes go full width too — no room for side floats on a
phone. */
.aside,
.margin {
float: none;
width: auto;
max-width: none;
margin: 0 0 1rem;
}
/* An explicit img width still shrink-wraps the figure (redeclared: this
block comes after the desktop rule at equal specificity). */
figure:has(img[width]) {
width: fit-content;
}
/* The 102rem sidebar/multicol .wide margins assume a left sidebar
column; with the sidebar on top the article is viewport-wide and the
plain centered bleed applies again. */
body:has(#sidebar):not(.editing) figure:has(.wide),
body:has(.multicol) figure:has(.wide) {
/* The single-column sidebar .wide margins assume a left sidebar column;
with the sidebar on top the article is viewport-wide and the plain
centered bleed applies again. (Multicol pages need no override: their
cqw bleed is exact at any width.) */
body:has(#sidebar):not(.editing):not(:has(.multicol)) figure:has(.wide) {
margin-inline: calc(50% - 50vw);
}
}
-59
View File
@@ -286,66 +286,8 @@ import "overlayscrollbars/overlayscrollbars.css";
// created page has no pen for commitPending's handover click.
renderAuthUi();
placeEditPen();
// Preview swaps also wipe the .colseg wrappers (the server render has
// none), which would drop the multi-column layout until a full reload;
// re-split so columns survive both live editing and closing the editor.
const main = document.getElementById("main");
if (main) applyMulticol(main);
});
// Multi-column layout only when there is enough text to justify it.
// Split the body into columned segments: h1s, h2s and wide elements are
// full-width separators and never go inside columns.
function applyMulticol(main) {
const article = main.querySelector("article");
if (!article) return;
const body = article.querySelector(".body");
// Code blocks don't read as flowing text and are often generated
// filler; exclude them when measuring whether the text justifies
// columns.
const textLen = (el) => {
let n = el.textContent.trim().length;
for (const pre of el.querySelectorAll("pre")) n -= pre.textContent.length;
return n;
};
article.classList.toggle(
"multicol",
!!body && textLen(body) > 1800,
);
if (body && article.classList.contains("multicol")
&& !body.querySelector(".colseg")) {
// h1s, h2s and wide elements (a {.wide} block or anything holding
// one, e.g. a figure with a wide image) are full-width separators
const isSeparator = (el) =>
el.tagName === "H1" || el.tagName === "H2"
|| el.classList.contains("wide")
|| el.querySelector(".wide") !== null;
let seg = null;
for (const el of [...body.children]) {
if (isSeparator(el)) {
seg = null;
body.append(el);
} else {
if (!seg) {
seg = document.createElement("div");
seg.className = "colseg";
body.append(seg);
}
seg.append(el);
}
}
// Columns are per section: only segments with enough text get them,
// so a short ingress or a brief section stays single-column. A
// .nocols container (::: nocols) opts its whole section out.
for (const s of body.querySelectorAll(".colseg")) {
s.classList.toggle(
"cols",
s.querySelector(".nocols") === null && textLen(s) > 600,
);
}
}
}
function applyEffects() {
(window.requestIdleCallback || setTimeout)(preload);
const main = document.getElementById("main");
@@ -355,7 +297,6 @@ import "overlayscrollbars/overlayscrollbars.css";
renderAuthUi();
placeEditPen();
fitNav();
applyMulticol(main);
if (reduceMotion.matches) return;
for (const el of main.querySelectorAll(
"h2, h3, figure, img, pre, blockquote, table, dl, .task-list-item",
+10 -5
View File
@@ -1015,15 +1015,20 @@ async def editor_ws(ws: WebSocket) -> None:
markdown = msg.get("markdown", "")
chain = resolve(data.menu, path)
node = chain[-1] if chain else None
await ws.send_json({
"type": "html",
"path": path,
"html": render(
rendered = render(
markdown,
path,
node.created if node else None,
node.modified if node else None,
),
)
await ws.send_json({
"type": "html",
"path": path,
"html": rendered.html,
# Column-layout flags: the preview toggles the
# article's .multicol class and swaps in the
# segmented (.colseg/.cols) body html.
"multicol": rendered.multicol,
"has_h1": has_h1(markdown),
})
case "save":
+145 -13
View File
@@ -11,8 +11,10 @@ same callout styling). ``::: name`` opens a generic container rendered
as ``<div class="name">`` and closed by a matching ``:::`` (nest by
giving the outer container more colons, e.g. `::::`); the name may be
followed by brace attributes (``::: aside {.right}``). ``::: aside``
floats as a side box beside the text and ``::: nocols`` opts its
section out of the column layout. A brace-attribute
floats as a muted side box, floating in the side zone at the article's
left on all but phone widths — the same margin float ``{.margin}`` (or
``::: margin``) gives any block — and ``::: nocols`` opts its section out
of the column layout. A brace-attribute
line as a block's last line (no blank line between) applies to the whole
block, e.g. a paragraph ending with ``{.wide}`` breaks out of the column
layout as a full-width element; written after a block (code fence,
@@ -21,6 +23,17 @@ the ``https://`` scheme hidden in the link text (``http://`` and other
schemes stay visible; manually labelled links are untouched), and
``H~2~O`` / ``x^2^`` give sub/superscripts.
render() also builds the layout structure: the top-level blocks are
segmented for the column layout — h1/h2 headings, ``.wide`` blocks and
margin-breakout blocks (``.margin``, ``::: aside``) stand on their own,
the runs between them are wrapped in ``<div class="colseg">`` (tagged
``.cols`` when the segment holds enough text, unless a ``::: nocols``
container opts it out). The result carries ``multicol`` when the whole
body justifies columns (views.py puts the class on the article); how
many columns (never more than two), whether the margin breakout applies
and every other viewport adaptation is then pagerite.css's call. The
thresholds measure visible text, code blocks excluded.
markdown-it's typographer is enabled, so body text gets SmartyPants-style
replacements: straight quotes become curly, ``--`` / ``---`` become en / em
dashes, ``...`` becomes an ellipsis, ``(c)`` becomes ©, and so on. Single
@@ -38,6 +51,7 @@ classes, e.g. `![alt](photo.avif "Caption"){.right}`.
import re
from datetime import datetime, timedelta
from typing import NamedTuple
from markdown_it import MarkdownIt
from markdown_it.common.utils import escapeHtml
@@ -219,16 +233,23 @@ def _container_validate(params: str, _markup: str) -> bool:
return pos == len(rest) - 1
def _container_render(self, tokens, idx, options, env):
"""Render `::: name {attrs}` containers as `<div class="name">`."""
token = tokens[idx]
if token.nesting == 1:
def _container_attrs(state) -> None:
"""Apply `::: name {attrs}` classes to container tokens at parse time.
The container plugin's default render is a plain renderToken, so the
name and brace attributes must live on the token itself — and being a
core rule (rather than a render rule) lets the segmentation in
render() see the classes (::: aside's margin breakout, the ::: nocols
opt-out, {.wide} containers).
"""
for token in state.tokens:
if token.type != "container_block_open":
continue
name, _, rest = token.info.strip().partition(" ")
token.attrJoin("class", name)
if rest.strip():
_, attrs = parse_attrs(rest.strip())
_apply_attrs(token, attrs)
return self.renderToken(tokens, idx, options, env)
def _block_attrs(state) -> None:
@@ -301,8 +322,7 @@ md = (
)
.use(attrs_plugin)
.use(admon_plugin)
.use(container_plugin, "block", validate=_container_validate,
render=_container_render)
.use(container_plugin, "block", validate=_container_validate)
.use(footnote_plugin)
.use(deflist_plugin)
.use(tasklists_plugin, enabled=True)
@@ -316,28 +336,140 @@ md.add_render_rule("fence", _fence_rule)
md.options["alerts"] = True
# Block attrs must be stripped before the typographer curlifies their quotes.
md.core.ruler.before("replacements", "block_attrs", _block_attrs)
md.core.ruler.push("container_attrs", _container_attrs)
md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
md.core.ruler.push("shorten_autolinks", _shorten_autolinks)
# Text-length thresholds (visible characters, code blocks excluded) for the
# column layout: the article goes .multicol past MULTICOL_TEXT, and a column
# segment gets .cols past COLS_TEXT.
MULTICOL_TEXT = 1800
COLS_TEXT = 600
_PRE_BLOCK_RE = re.compile(r"<pre\b.*?</pre>", re.S)
_TAG_RE = re.compile(r"<[^>]+>")
# Classes that take their block out of the column flow: .wide is a
# full-width separator, .margin/.aside float in the side zone at the
# article's left (they must be direct .body children for that — the zone
# rules key off it — never inside a column).
_WIDE = "wide"
_BREAKOUT = ("margin", "aside")
class Rendered(NamedTuple):
"""render() result: the segmented body HTML, and whether the article
should carry .multicol (enough visible text to justify columns)."""
html: str
multicol: bool
def _classes(token) -> set[str]:
return set((token.attrGet("class") or "").split())
def _text_len(html: str) -> int:
"""Visible-text length of rendered HTML, code blocks excluded."""
return len(_TAG_RE.sub("", _PRE_BLOCK_RE.sub("", html)).strip())
def _top_level_blocks(tokens: list) -> list[list]:
"""Split the token stream into its top-level blocks.
A new block starts at each level-0 opening/self-contained token;
closing and nested tokens (inline children, sub-containers) belong to
the current block, so every slice is balanced and renders on its own.
"""
blocks = []
for token in tokens:
if token.level == 0 and token.nesting >= 0:
blocks.append([token])
elif blocks:
blocks[-1].append(token)
return blocks
def _is_boundary(block: list) -> bool:
"""True for blocks that never go inside a column segment (see the
_WIDE/_BREAKOUT comment above): h1/h2 headings, anything carrying
.wide, and blocks whose own element carries .margin/.aside — for a
lone-image paragraph (which renders as a <figure>) the image's classes
count as the block's own."""
first = block[0]
if first.type == "heading_open" and first.tag in ("h1", "h2"):
return True
own = _classes(first)
for token in block:
if _WIDE in _classes(token):
return True
if token.type == "inline":
children = token.children or []
if any(_WIDE in _classes(c) for c in children):
return True
if len(children) == 1 and children[0].type == "image":
own |= _classes(children[0])
return bool(own & set(_BREAKOUT))
def render(
text: str,
page_path: str = "",
created: datetime | None = None,
modified: datetime | None = None,
) -> str:
"""Render Markdown text to an HTML string.
) -> Rendered:
"""Render Markdown text to the article body's HTML and layout flags.
The top-level blocks are grouped into column segments: boundary blocks
(h1/h2 headings, .wide, margin-breakout blocks — see _is_boundary) are
rendered bare, the runs between them wrapped in <div class="colseg">.
A segment is tagged .cols when it holds enough text (COLS_TEXT) and no
::: nocols container; the article is .multicol when the whole body
exceeds MULTICOL_TEXT. pagerite.css keys all column and margin-breakout
layout off these classes.
A ``{dates}`` line expands to the article's published/updated dateline
(needs ``created``/``modified``; left as-is in contexts without them,
e.g. the editor preview). Position is the author's choice — typically
right after the article's h1.
"""
html = md.render(text, {"page_path": page_path})
env = {"page_path": page_path}
blocks = _top_level_blocks(md.parse(text, env))
# Group consecutive non-boundary blocks into segments (is_segment,
# flat tokens); boundary blocks stand on their own between them.
groups: list[tuple[bool, list]] = []
for block in blocks:
if _is_boundary(block):
groups.append((False, block))
elif groups and groups[-1][0]:
groups[-1][1].extend(block)
else:
groups.append((True, list(block)))
parts = []
total = 0
for is_segment, group in groups:
html = md.renderer.render(group, md.options, env)
if not html.strip():
continue # e.g. a consumed standalone-attrs paragraph
text_len = _text_len(html)
total += text_len
if not is_segment:
parts.append(html)
continue
nocols = any(
"nocols" in _classes(t)
for t in group
if t.type == "container_block_open"
)
cols = " cols" if text_len > COLS_TEXT and not nocols else ""
parts.append(f'<div class="colseg{cols}">{html}</div>')
html = "".join(parts)
if created is not None and "<p>{dates}</p>" in html:
html = html.replace("<p>{dates}</p>", _dateline(created, modified))
return html
return Rendered(html, total > MULTICOL_TEXT)
def _dateline(created: datetime, modified: datetime | None) -> str:
+7 -5
View File
@@ -513,16 +513,18 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
def page_content(menu: dict[str, Node], path: str) -> HTML:
"""Render the contents of the #main element for a page."""
node = resolve(menu, path)[-1]
doc = E.article
rendered = render(node.content or "", path, node.created, node.modified)
# Long articles get .multicol: the article column cap lifts (see the
# #content grid in pagerite.css) and the body's .cols segments lay out
# in at most two columns. The .body html is already segmented by
# render() — the whole layout is driven by these classes.
doc = E.article(class_="multicol") if rendered.multicol else E.article
with doc:
# 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(render(node.content or "", path, node.created, node.modified)),
class_="body",
)
doc.div(HTML(rendered.html), class_="body")
return HTML(str(doc))