Layout: breakable paragraphs across columns, sidebar gets its own flexible track at every width, cols requires multiple paragraphs; h3 size fix in corporate theme

This commit is contained in:
2026-08-28 23:23:51 +00:00
parent 5101a5c5bf
commit 22c4f0e752
5 changed files with 188 additions and 87 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages). markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages).
`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). `render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `<article>` children) — h1/h2 headings, `.wide` blocks and margin-breakout blocks (`.margin`, `::: aside`) stand bare, the runs between them become `<div class="colseg">` (plus `.cols` on segments with enough text in at least two paragraphs or one long enough to split across columns, `::: nocols` opting out; in column segments, paragraphs past `BREAKABLE_TEXT` visible characters are marked `.breakable` so they may split across columns), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation).
## `views.py` ## `views.py`
+1 -1
View File
@@ -34,7 +34,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
## Reading experience ## Reading experience
- The article column is sized by the **viewport, never by content**: a symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it. Long articles (flagged `.multicol` by the backend render) lift the cap and become a bounded **composition**, centered in the available space with the surplus left vacant: a fluid text lane (up to 42rem) plus a 16rem **side zone at the article's left** — the region the nav sidebar overlays — which hosts margin boxes (`.margin`, `::: aside`, margin figures) at all but phone widths, without the text ever moving. On pages with a sidebar 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. - The article column is sized by the **viewport, never by content**: a symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it. Long articles (flagged `.multicol` by the backend render) lift the cap and become a bounded **composition**, centered in the available space with the surplus left vacant: a fluid text lane (up to 42rem) plus a 16rem **side zone at the article's left** — the region the nav sidebar overlays — which hosts margin boxes (`.margin`, `::: aside`, margin figures) at all but phone widths, without the text ever moving. On pages with a sidebar, the sidebar gets its own track at every width — flexible, 12rem when space is tight and growing up to 150% (18rem) once the viewport has room beyond the article, the sidebar keeping its left side on the viewport's edge — and the track is the left lane instead: no in-article zone, the text lane runs fluid up to 86rem leaning on the viewport's right edge (surplus extends the left lane), and the boxes hang into the lane off the article's left border (growing leftward with it, up to 18rem), sliding under the translucent sticky nav. Once two lanes fit beside the zone (≥96rem available in `main`), the text flows in two fluid lanes (36rem minimum, capped at 102rem total — technical content wants the wider lanes, and wider windows just add vacant space). The stages step by the space actually available in `main` (container queries + `cqw` units, so the docked editor's inset is automatic). `.wide` figures on multicol pages bleed to the viewport edges measured from `main` (`cqw`), sliding under the sidebar. The backend splits the body into `.colseg` segments at h1/h2 headings, `.wide` elements and margin blocks (full-width separators or margin boxes, never inside columns), tagging segments that hold enough text in at least two paragraphs (or one long enough to split) with `.cols` — code blocks are excluded from that measure, a `::: nocols` container opts its whole section out, and column-filling paragraphs are marked `.breakable` so they may split across the column gap (shorter paragraphs stay whole). On wide single-column pages (≥104rem), margin boxes lean into the vacant left gutter as well, growing with it up to 18rem.
- A gentle **scroll-reveal** of headings, figures and block-level elements (IntersectionObserver). It is layout-level: articles need no support for it, and `prefers-reduced-motion` disables all motion. - A gentle **scroll-reveal** of headings, figures and block-level elements (IntersectionObserver). It is layout-level: articles need no support for it, and `prefers-reduced-motion` disables all motion.
## Styling ## Styling
+126 -72
View File
@@ -278,22 +278,25 @@ body {
/* Long articles (.multicol comes from the backend render, based on /* Long articles (.multicol comes from the backend render, based on
content length — code excluded) lift the 78rem cap: main takes the full content length — code excluded) lift the 78rem cap: main takes the full
width and the article composes itself inside it — fluid, bounded text width and the article composes itself inside it — fluid, bounded text
lanes, centered, with the surplus left vacant (see the article layout lanes with the surplus left vacant (see the article layout rules
rules below). The left track — main always sits in column 2 — collapses below). The left track — main always sits in column 2 — collapses to
to zero when the page has no sidebar; the sidebar then simply overlays zero when the page has no sidebar; the sidebar then simply overlays
the vacant zone, as it does on single-column pages. */ the vacant zone, as it does on single-column pages. */
body:has(.multicol) #content { body:has(.multicol) #content {
grid-template-columns: 0 minmax(0, 1fr); grid-template-columns: 0 minmax(0, 1fr);
} }
/* Only when the vacant zone cannot hold the sidebar does it get its own /* With a sidebar the left lane gets its own track at every width, so the
12rem track — the same compromise single-column pages make below sidebar never overlaps the article and the article leans on the
102rem. Above that, a sidebar's presence changes nothing about the viewport's right edge (surplus extends the lane). The lane is flexible:
article. */ 12rem when space is tight, growing up to 150% (18rem) once the viewport
@media (max-width: 110rem) { exceeds the article's 88.5rem (86rem + main's side padding). The --lane
body:has(#sidebar):has(.multicol):not(.editing) #content { variable doubles as the measure for the margin boxes and the .wide
grid-template-columns: 12rem minmax(0, 1fr); bleed below. (Inert below 48rem, where #content becomes a flex column
} with the sidebar on top.) */
body:has(#sidebar):has(.multicol):not(.editing) #content {
--lane: clamp(12rem, 100vw - 88.5rem, 18rem);
grid-template-columns: var(--lane) minmax(0, 1fr);
} }
body.editing #content { body.editing #content {
@@ -624,6 +627,16 @@ article * + h6 {
margin-top: 1.4rem; margin-top: 1.4rem;
} }
/* Margin boxes are compact: the section-separator gaps above also reach
headings inside .aside/.margin (they follow the box's paragraphs, so
the sibling combinator matches) — bring them back to plain block
spacing. First-child headings keep their zero via the sibling
combinator, as everywhere. */
.aside * + :is(h1, h2, h3, h4, h5, h6),
.margin * + :is(h1, h2, h3, h4, h5, h6) {
margin-top: 1rem;
}
/* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels, /* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels,
orange 🔸 on even; ordered lists get muted counters (an explicit orange 🔸 on even; ordered lists get muted counters (an explicit
[type] attribute keeps the browser's native markers). The marker box [type] attribute keeps the browser's native markers). The marker box
@@ -768,15 +781,18 @@ article dd {
/* The long-article composition (.multicol): a fluid but bounded text /* The long-article composition (.multicol): a fluid but bounded text
lane with a 16rem side zone at the article's left, centered in main — lane with a 16rem side zone at the article's left, centered in main —
surplus width becomes vacant space, never endless text. (The backend surplus width becomes vacant space, never endless text (with a sidebar
render splits the body into .colseg segments separated by full-width the article leans right instead and the sidebar's track is the left
h2s and .wide elements, tags text-heavy segments .cols — a ::: nocols lane; see below). (The backend render splits the body into .colseg
container opts its section out — and flags the article .multicol; CSS segments separated by full-width h2s and .wide elements, tags
owns the geometry.) Technical content wants a wide lane: up to 42rem text-heavy segments of several paragraphs .cols — a ::: nocols
single, or two fluid lanes (36rem minimum, never more than two) once container opts its section out, and column-filling paragraphs are
they fit beside the zone, capped at 102rem total. The zone — the marked .breakable — and flags the article .multicol; CSS owns the
region the nav sidebar overlays — is a margin indent on the lane geometry.) Technical content wants a wide lane: up to 42rem single, or
content; margin boxes float into it, and the text never moves. */ 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 { article.multicol {
margin-inline: auto; margin-inline: auto;
max-width: 58rem; /* 42rem lane + 16rem zone */ max-width: 58rem; /* 42rem lane + 16rem zone */
@@ -789,15 +805,15 @@ article.multicol {
overlays. Scoped to direct article 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 margin blocks out of the column segments); nested ones keep the
in-column float fallback. */ in-column float fallback. */
.multicol article>.colseg, article.multicol>.colseg,
.multicol article>h1, article.multicol>h1,
.multicol article>h2 { article.multicol>h2 {
margin-left: 16rem; margin-left: 16rem;
} }
.multicol article>.margin, article.multicol>.margin,
.multicol article>.aside, article.multicol>.aside,
.multicol article>figure:has(.margin) { article.multicol>figure:has(.margin) {
float: left; float: left;
clear: left; clear: left;
width: 14rem; width: 14rem;
@@ -807,9 +823,9 @@ article.multicol {
/* Wide separators start below any margin box — their bleed must not /* Wide separators start below any margin box — their bleed must not
wrap around it. */ wrap around it. */
.multicol article>figure:has(.wide), article.multicol>figure:has(.wide),
.multicol article>div.wide, article.multicol>div.wide,
.multicol article>pre.wide { article.multicol>pre.wide {
clear: left; clear: left;
} }
} }
@@ -828,35 +844,46 @@ article.multicol {
} }
} }
/* With a sidebar, below 110rem the sidebar gets its own 12rem track (see /* With a sidebar, the sidebar's 12rem track IS the left lane at every
#content). The track IS the left lane then: no in-article zone, the width (see #content): no in-article zone, the text lane runs fluid (up
text lane runs fluid (up to 86rem), and margin boxes fall all the way to 86rem) and leans on main's right edge — surplus width extends the
left into the track — sliding under the translucent sticky nav, which left lane instead of balancing out on the right — and margin boxes
only ever occupies its top. (The track never exists once the container hang into the lane off the article's left border, sliding under the
reaches 96rem, so the two-lane rules above never meet it. Not below translucent sticky nav, which only ever occupies its top. (Not below
48rem: there the sidebar becomes a link strip above the article and 48rem: there the sidebar becomes a link strip above the article and
there is no track to fall into.) */ there is no lane to fall into.) */
@media (min-width: 48rem) and (max-width: 110rem) { @media (min-width: 48rem) {
body:has(#sidebar):has(.multicol):not(.editing) article.multicol { body:has(#sidebar):has(.multicol):not(.editing) article.multicol {
max-width: 86rem; max-width: 86rem;
margin-inline: auto 0;
} }
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>.colseg, /* The sidebar fills the flexible lane (its left side stays on the
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>h1, viewport's left edge, growing rightward). */
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>h2 { body:has(#sidebar):has(.multicol):not(.editing) #sidebar {
width: 100%;
}
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.colseg,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>h1,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>h2 {
margin-left: 0; margin-left: 0;
} }
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>.margin, body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.margin,
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>.aside, body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.aside,
body:has(#sidebar):has(.multicol):not(.editing) .multicol article>figure:has(.margin) { body:has(#sidebar):has(.multicol):not(.editing) article.multicol>figure:has(.margin) {
float: left; float: left;
clear: left; clear: left;
width: 12rem; /* Attached to the article's left border (1.25rem gap), hanging into
the left lane and growing leftward with it: 12rem when the lane is
tight, up to 150% (18rem) when the track or the surplus has room
(100cqw - 100% is the surplus left of the right-leaning article).
The lane (track + main's padding) always guarantees the room. */
--box-w: min(18rem, var(--lane) + 100cqw - 100% - 1.25rem);
width: var(--box-w);
max-width: none; max-width: none;
/* From the text's left edge to the page's left edge: half the margin: 0.3rem 0 1rem calc(-1.25rem - var(--box-w));
centering difference plus the track and main's padding. */
margin: 0.3rem 0 1rem calc(50% - 50cqw - 13.25rem);
} }
} }
@@ -882,6 +909,13 @@ article.multicol {
break-inside: avoid-column; break-inside: avoid-column;
} }
/* Column-filling paragraphs (marked .breakable by the backend render)
are allowed to split — keeping them whole would strand the following
content in a column of its own. */
p.breakable {
break-inside: auto;
}
figure, figure,
pre, pre,
blockquote, blockquote,
@@ -1258,16 +1292,27 @@ figure:has(.margin) {
/* Wide single-column pages: margin boxes lean into the vacant left /* Wide single-column pages: margin boxes lean into the vacant left
gutter instead (below 104rem the gutter cannot hold the box, and while gutter instead (below 104rem the gutter cannot hold the box, and while
editing the docked panel reshapes the gutters — in both they stay editing the docked panel reshapes the gutters — in both they stay
plain floats). */ plain floats). The box grows with the gutter up to 150% (18rem), its
right side 1.25rem off the article's left border. */
@media (min-width: 104rem) { @media (min-width: 104rem) {
body:not(.editing):not(:has(.multicol)) article>.margin, body:not(.editing):not(:has(.multicol)) article>.margin,
body:not(.editing):not(:has(.multicol)) article>.aside, body:not(.editing):not(:has(.multicol)) article>.aside,
body:not(.editing):not(:has(.multicol)) article>figure:has(.margin) { body:not(.editing):not(:has(.multicol)) article>figure:has(.margin) {
float: left; float: left;
clear: left; clear: left;
width: 12rem; --box-w: min(18rem, (100vw - 78rem) / 2 - 1.25rem);
width: var(--box-w);
max-width: none; max-width: none;
margin: 0.3rem 0 1rem -13.25rem; margin: 0.3rem 0 1rem calc(-1.25rem - var(--box-w));
}
}
/* In the wide symmetric gutters (where the sidebar overlays the flexible
left gutter rather than a reserved track) the sidebar flexes with the
gutter up to 150% — its left side stays on the viewport's edge. */
@media (min-width: 102rem) {
body:not(.editing) #sidebar {
width: min(18rem, 100%);
} }
} }
@@ -1312,19 +1357,19 @@ body.editing pre.wide {
} }
/* Narrow single-column pages with a sidebar: below 102rem the symmetric /* Narrow single-column pages with a sidebar: below 102rem the symmetric
gutters can no longer both hold the 12rem sidebar, so #content reserves gutters can no longer both hold the sidebar, so #content reserves it
it with a fixed left track (see the matching media query below) and the with a flexible left track (see the matching media query below) and the
article always starts at 12rem (+ main's 1.25rem padding) — the bleed article always starts at the lane's width (+ main's 1.25rem padding) —
margin is a plain constant. Scoped by :has(#sidebar) since the sidebar the bleed margin measures off --lane. Scoped by :has(#sidebar) since
element is omitted entirely on pages without sub-navigation, and the sidebar element is omitted entirely on pages without
excluded while editing, where the editing rules above apply instead. sub-navigation, and excluded while editing, where the editing rules
(Multicol pages use the same fixed track below 110rem — their rules above apply instead. (Multicol pages reserve the same track at every
are below.) */ width — their rules are below.) */
@media (max-width: 102rem) { @media (max-width: 102rem) {
body:has(#sidebar):not(.editing):not(:has(.multicol)) figure:has(.wide), body:has(#sidebar):not(.editing):not(:has(.multicol)) figure:has(.wide),
body:has(#sidebar):not(.editing):not(:has(.multicol)) div.wide, body:has(#sidebar):not(.editing):not(:has(.multicol)) div.wide,
body:has(#sidebar):not(.editing):not(:has(.multicol)) pre.wide { body:has(#sidebar):not(.editing):not(:has(.multicol)) pre.wide {
margin-inline: -13.25rem 0; margin-inline: calc(-1.25rem - var(--lane)) 0;
} }
} }
@@ -1339,15 +1384,17 @@ body:has(.multicol) pre.wide {
margin-inline: calc(50% - 50cqw - 1.25rem); margin-inline: calc(50% - 50cqw - 1.25rem);
} }
/* Multicol with a sidebar track (≤110rem, see #content): main starts /* Multicol with a sidebar track (≥48rem, see #content): main starts at
12rem in, so the bleed extends left past the track to the true viewport the flexible lane's width and the article leans right, so the bleed
edge — sliding under the translucent sidebar. */ extends left past the surplus and the lane to the true viewport edge —
@media (max-width: 110rem) { sliding under the translucent sidebar — and right past main's
padding. */
@media (min-width: 48rem) {
body:has(#sidebar):has(.multicol):not(.editing) figure:has(.wide), 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) div.wide,
body:has(#sidebar):has(.multicol):not(.editing) pre.wide { body:has(#sidebar):has(.multicol):not(.editing) pre.wide {
width: calc(100cqw + 14.5rem); width: calc(100cqw + 2.5rem + var(--lane));
margin-inline: calc(50% - 50cqw - 13.25rem) 0; margin-inline: calc(100% - 100cqw - 1.25rem - var(--lane)) 0;
} }
} }
@@ -1383,16 +1430,23 @@ article h2 {
} }
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no /* 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 longer both hold the 12rem sidebar, so reserve its space with a flexible
left track instead of letting it overlap the article (multicol pages use left track instead of letting it overlap the article (multicol pages use
this same track at every width — see the #content rules above). The the same track at every width — see the #content rules above; their
article then always starts at 12rem (+ main's 1.25rem padding), which higher-specificity rule wins there). The lane is 12rem when space is
the matching .wide breakout rule in the images section relies on. tight, growing up to 150% (18rem) once the viewport exceeds the
Scoped by :has(#sidebar) since the sidebar element is omitted entirely article's 80.5rem (78rem + main's side padding), and the sidebar fills
on pages without sub-navigation. */ it. The matching .wide breakout rule in the images section measures off
--lane. Scoped by :has(#sidebar) since the sidebar element is omitted
entirely on pages without sub-navigation. */
@media (max-width: 102rem) { @media (max-width: 102rem) {
body:has(#sidebar):not(.editing) #content { body:has(#sidebar):not(.editing) #content {
grid-template-columns: 12rem minmax(0, 78rem) minmax(0, 1fr); --lane: clamp(12rem, 100vw - 80.5rem, 18rem);
grid-template-columns: var(--lane) minmax(0, 78rem) minmax(0, 1fr);
}
body:has(#sidebar):not(.editing) #sidebar {
width: 100%;
} }
} }
+60 -12
View File
@@ -27,12 +27,16 @@ render() also builds the layout structure: the top-level blocks are
segmented for the column layout — h1/h2 headings, ``.wide`` blocks and segmented for the column layout — h1/h2 headings, ``.wide`` blocks and
margin-breakout blocks (``.margin``, ``::: aside``) stand on their own, margin-breakout blocks (``.margin``, ``::: aside``) stand on their own,
the runs between them are wrapped in ``<div class="colseg">`` (tagged the runs between them are wrapped in ``<div class="colseg">`` (tagged
``.cols`` when the segment holds enough text, unless a ``::: nocols`` ``.cols`` when the segment holds enough text — COLS_TEXT — in at least
container opts it out). The result carries ``multicol`` when the whole COLS_PARAS paragraphs or one paragraph long enough to turn .breakable,
body justifies columns (views.py puts the class on the article); how unless a ``::: nocols`` container opts it out;
many columns (never more than two), whether the margin breakout applies in column segments, paragraphs past BREAKABLE_TEXT are marked
and every other viewport adaptation is then pagerite.css's call. The ``.breakable`` so they may split across columns). The result carries
thresholds measure visible text, code blocks excluded. ``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 markdown-it's typographer is enabled, so body text gets SmartyPants-style
replacements: straight quotes become curly, ``--`` / ``---`` become en / em replacements: straight quotes become curly, ``--`` / ``---`` become en / em
@@ -434,12 +438,23 @@ md.core.ruler.push("heading_ids", _heading_ids)
# Text-length thresholds (visible characters, code blocks excluded) for the # Text-length thresholds (visible characters, code blocks excluded) for the
# column layout: the article goes .multicol past MULTICOL_TEXT, and a column # column layout: the article goes .multicol past MULTICOL_TEXT, and a column
# segment gets .cols past COLS_TEXT. # segment gets .cols past COLS_TEXT — provided it also has at least
# COLS_PARAS paragraphs or a paragraph long enough to turn .breakable: a
# lone unbreakable paragraph would fill a column on its own and strand the
# rest (e.g. a floated figure) in the other, leaving a mostly empty column.
MULTICOL_TEXT = 1800 MULTICOL_TEXT = 1800
COLS_TEXT = 600 COLS_TEXT = 600
COLS_PARAS = 2
#: Paragraphs past this visible length are marked .breakable, letting them
#: split across columns (shorter ones stay unbreakable so a paragraph never
#: straddles the column gap).
BREAKABLE_TEXT = 800
_PRE_BLOCK_RE = re.compile(r"<pre\b.*?</pre>", re.S) _PRE_BLOCK_RE = re.compile(r"<pre\b.*?</pre>", re.S)
_TAG_RE = re.compile(r"<[^>]+>") _TAG_RE = re.compile(r"<[^>]+>")
_PARA_OPEN_RE = re.compile(r"<p[\s>]")
_PARA_RE = re.compile(r"<p((?:\s[^>]*)?)>(.*?)</p>", re.S)
# Classes that take their block out of the column flow: .wide is a # Classes that take their block out of the column flow: .wide is a
# full-width separator, .margin/.aside float in the side zone at the # full-width separator, .margin/.aside float in the side zone at the
@@ -466,6 +481,28 @@ def _text_len(html: str) -> int:
return len(_TAG_RE.sub("", _PRE_BLOCK_RE.sub("", html)).strip()) return len(_TAG_RE.sub("", _PRE_BLOCK_RE.sub("", html)).strip())
def _breakable_paras(html: str) -> str:
"""Mark column-filling paragraphs .breakable so they may split.
Columns keep paragraphs whole (break-inside: avoid-column), but a
paragraph long enough to fill a column would strand everything after
it in a column of its own — these get .breakable, and pagerite.css
lets them split across the column gap. Only applied to .cols segments.
"""
def repl(m: re.Match[str]) -> str:
attrs, body = m.group(1), m.group(2)
if _text_len(body) <= BREAKABLE_TEXT:
return m.group(0)
if 'class="' in attrs:
attrs = attrs.replace('class="', 'class="breakable ', 1)
else:
attrs = f'{attrs} class="breakable"'
return f"<p{attrs}>{body}</p>"
return _PARA_RE.sub(repl, html)
def _top_level_blocks(tokens: list) -> list[list]: def _top_level_blocks(tokens: list) -> list[list]:
"""Split the token stream into its top-level blocks. """Split the token stream into its top-level blocks.
@@ -520,10 +557,12 @@ def render(
The top-level blocks are grouped into column segments: boundary blocks The top-level blocks are grouped into column segments: boundary blocks
(h1/h2 headings, .wide, margin-breakout blocks — see _is_boundary) are (h1/h2 headings, .wide, margin-breakout blocks — see _is_boundary) are
rendered bare, the runs between them wrapped in <div class="colseg">. 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 A segment is tagged .cols when it holds enough text (COLS_TEXT) in at
::: nocols container; the article is .multicol when the whole body least two paragraphs (COLS_PARAS) or one breakable-length paragraph,
exceeds MULTICOL_TEXT. pagerite.css keys all column and margin-breakout and no ::: nocols container; its long paragraphs are marked .breakable;
layout off these classes. 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 A ``{dates}`` line expands to the article's published/updated dateline
(needs ``created``/``modified``; left as-is in contexts without them, (needs ``created``/``modified``; left as-is in contexts without them,
@@ -562,7 +601,16 @@ def render(
nocols = any( 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 "" marked = _breakable_paras(html)
cols = (
" cols"
if text_len > COLS_TEXT
and not nocols
and (len(_PARA_OPEN_RE.findall(html)) >= COLS_PARAS or marked != html)
else ""
)
if cols:
html = marked
parts.append(f'<div class="colseg{cols}">{html}</div>') parts.append(f'<div class="colseg{cols}">{html}</div>')
html = "".join(parts) html = "".join(parts)
if created is not None and "<p>{dates}</p>" in html: if created is not None and "<p>{dates}</p>" in html:
-1
View File
@@ -106,7 +106,6 @@ article h2 {
article h3 { article h3 {
font-weight: 700; font-weight: 700;
font-size: 0.95rem;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: var(--muted); color: var(--muted);
} }