Compare commits

...
2 Commits
Author SHA1 Message Date
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 268 additions and 120 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). 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` ## `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. 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.). - 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. - **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. - 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 — both drop into the left margin when the layout has room for it (the sticky sidebar shares that gutter and slides over them translucently, like full-bleed images), and stay in-column floats otherwise — 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. - **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}` drops it into the left margin like a margin note, `{.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 ## Page structure and navigation
@@ -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; 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 78rem cap and take everything right of the left gutter, out to the viewport edge. The backend also splits the body into `.colseg` segments at h1/h2 headings, `.wide` elements and margin-breakout 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. The CSS then fits **at most two columns** of at least 30rem per segment, so a wider window widens the pair instead of adding columns. Margin boxes (`.margin`, `::: aside`) leave the column flow for the left gutter on wide viewports and stay in-column floats below that.
- 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
+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') const article = document.querySelector('#main article')
if (!article) return 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 h1 = article.querySelector('h1')
const body = article.querySelector('.body') const body = article.querySelector('.body')
// The edit pen may be tucked inside an h1 (title or markdown-owned); // The edit pen may be tucked inside an h1 (title or markdown-owned);
@@ -270,7 +274,7 @@ function onMessage(ev) {
requestRender() requestRender()
dirty.value = false // just loaded from the server, nothing unsaved dirty.value = false // just loaded from the server, nothing unsaved
} else if (msg.type === 'html' && msg.path === path.value) { } 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') { } else if (msg.type === 'saved') {
saveError.value = '' saveError.value = ''
pendingSave = null pendingSave = null
+94 -32
View File
@@ -275,12 +275,13 @@ body {
grid-template-columns: minmax(0, 1fr) minmax(0, 78rem) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 78rem) minmax(0, 1fr);
} }
/* Long articles (.multicol is added by pagerite.js based on content length) /* Long articles (.multicol comes from the backend render, based on
lift the 78rem cap and scrap the right gutter: a 1fr left gutter (which content length — code excluded) lift the 78rem cap and scrap the right
holds the overlaying sidebar) and the article taking all the rest, out gutter: a 1fr left gutter (which holds the overlaying sidebar and any
to the right viewport edge. The column count follows the width (see the margin-breakout boxes) and the article taking all the rest, out to the
`columns: 30rem` rule below). The .wide breakout is re-anchored to the right viewport edge. Columns are capped at two (see the
left gutter below (the article is no longer viewport-centered). */ `columns: 30rem 2` rule below). The .wide breakout is re-anchored to
the left gutter below (the article is no longer viewport-centered). */
body:has(.multicol) #content { body:has(.multicol) #content {
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr); grid-template-columns: minmax(0, 1fr) minmax(0, 4fr);
} }
@@ -561,20 +562,28 @@ article dd {
hyphens: auto; hyphens: auto;
} }
/* Multi-column reading, but only for long articles (pagerite.js adds /* Multi-column reading, but only for long articles: the backend render
.multicol based on content length — code blocks excluded — and splits splits the body into .colseg segments (separated by full-width h2s and
the body into .colseg segments separated by full-width h2s and .wide .wide elements), tags segments with enough text as .cols (a ::: nocols
elements; only segments with enough text get .cols, and a ::: nocols container opts its section out) and flags the article .multicol based
container opts its section out). No fixed breakpoint: `columns: 30rem` lets CSS fit as on content length — code blocks excluded. Never more than two columns:
many columns of at least 30rem as the article's current width allows — `columns: 30rem 2` fits one or two columns of at least 30rem into the
since .multicol also uncaps the article width (see #content above), a article's current width — since .multicol also uncaps the article
wider window simply yields more columns. */ width (see #content above), a wider window widens the two columns
instead of adding more. */
.multicol .colseg.cols { .multicol .colseg.cols {
columns: 30rem; columns: 30rem 2;
column-gap: 3.5rem; column-gap: 3.5rem;
column-rule: 1px solid var(--line); column-rule: 1px solid var(--line);
} }
/* 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 { .multicol .colseg {
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -594,6 +603,8 @@ article dd {
pre, pre,
blockquote, blockquote,
table, table,
ul,
ol,
dl, dl,
.admonition, .admonition,
.markdown-alert { .markdown-alert {
@@ -723,13 +734,13 @@ blockquote p + p {
--admonition-color: var(--accent3); --admonition-color: var(--accent3);
} }
/* Asides (::: aside): a floated side box in the floated-figure idiom; /* Side boxes: ::: aside is a muted floated box (consecutive asides stack
consecutive asides stack (clear: right). On wide single-column pages it via clear: right); {.margin} / ::: margin is a plainer margin note, and
leans into the empty right gutter (below 104rem the gutter cannot hold figures take {.margin} like {.left}. All of them drop into the left
the box; multicol pages have no right gutter at all, and while editing margin when the layout has room for it (the breakout rules live with
the docked panel reshapes the gutters — in all these it stays a plain the figure rules below); without room they stay in-column floats —
float). Headings already clear floats, so asides never bleed into the asides on the right, margin boxes on the left. Headings already clear
next section. */ floats, so boxes never bleed into the next section. */
.aside { .aside {
float: right; float: right;
clear: right; clear: right;
@@ -743,15 +754,19 @@ blockquote p + p {
border-radius: 0.3rem; border-radius: 0.3rem;
} }
.aside> :last-child { .aside> :last-child,
.margin> :last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@media (min-width: 104rem) { .margin {
body:not(.editing):not(:has(.multicol)) .aside { float: left;
width: 12rem; clear: left;
margin-right: -13rem; width: 30%;
} max-width: 20rem;
margin: 0.3rem 1.2rem 1rem 0;
font-size: 0.9rem;
color: var(--muted);
} }
pre { pre {
@@ -915,6 +930,52 @@ figure:has(img[width]) {
width: fit-content; width: fit-content;
} }
/* {.margin} figures float left like {.left} ones — until the margin
breakout below pulls them into the left gutter. */
figure:has(.margin) {
float: left;
width: 30%;
max-width: 50%;
margin: 0.3rem 1em 1rem 0;
}
/* The left-margin breakout: margin boxes ({.margin} / ::: margin blocks,
::: aside, {.margin} figures) leave the text column for the left
gutter, hugging the article's left edge (a 12rem box on a 13.25rem
pull: the gutter plus main's padding). The sticky sidebar shares the
gutter and slides over them translucently — the same overlap full-bleed
.wide images get. Scoped to direct .body children: the backend render
keeps breakout blocks out of the column segments, and nested ones keep
the in-column float fallback. Room exists once the single-column
layout's symmetric gutters fit the box (≥104rem), and on multicol
pages whose 1fr left gutter fits it (≥65rem) — but never while editing
(the docked panel owns the left edge), and not below 102rem with a
sidebar (the fixed 12rem sidebar track, see #content, leaves no free
gutter there). */
@media (min-width: 104rem) {
body:not(.editing) .body>.margin,
body:not(.editing) .body>.aside,
body:not(.editing) .body>figure:has(.margin) {
float: left;
clear: left;
width: 12rem;
max-width: none;
margin: 0.3rem 0 1rem -13.25rem;
}
}
@media (min-width: 65rem) {
body:not(.editing):has(.multicol):not(:has(#sidebar)) .body>.margin,
body:not(.editing):has(.multicol):not(:has(#sidebar)) .body>.aside,
body:not(.editing):has(.multicol):not(:has(#sidebar)) .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 /* .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 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 space. The article column is centered in the available space, so negative
@@ -1036,9 +1097,9 @@ article h2 {
/* Phones and other narrow viewports: single-column layout with the /* Phones and other narrow viewports: single-column layout with the
sidebar lifted above the article as a wrapping link strip, and no sidebar lifted above the article as a wrapping link strip, and no
floated figures — .left/.right fall back to plain centered figures floated figures — .left/.right/.margin fall back to plain centered
(explicit img widths still shrink-wrap), while .wide keeps its full figures (explicit img widths still shrink-wrap), while .wide keeps its
viewport bleed. */ full viewport bleed. */
@media (max-width: 48rem) { @media (max-width: 48rem) {
/* Nav type shrinks fluidly as space runs out. The nav font-size is /* Nav type shrinks fluidly as space runs out. The nav font-size is
@@ -1103,7 +1164,8 @@ article h2 {
} }
figure:has(.right), figure:has(.right),
figure:has(.left) { figure:has(.left),
figure:has(.margin) {
float: none; float: none;
width: 100%; width: 100%;
max-width: none; max-width: none;
-59
View File
@@ -286,66 +286,8 @@ import "overlayscrollbars/overlayscrollbars.css";
// created page has no pen for commitPending's handover click. // created page has no pen for commitPending's handover click.
renderAuthUi(); renderAuthUi();
placeEditPen(); 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() { function applyEffects() {
(window.requestIdleCallback || setTimeout)(preload); (window.requestIdleCallback || setTimeout)(preload);
const main = document.getElementById("main"); const main = document.getElementById("main");
@@ -355,7 +297,6 @@ import "overlayscrollbars/overlayscrollbars.css";
renderAuthUi(); renderAuthUi();
placeEditPen(); placeEditPen();
fitNav(); fitNav();
applyMulticol(main);
if (reduceMotion.matches) return; if (reduceMotion.matches) return;
for (const el of main.querySelectorAll( for (const el of main.querySelectorAll(
"h2, h3, figure, img, pre, blockquote, table, dl, .task-list-item", "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", "") markdown = msg.get("markdown", "")
chain = resolve(data.menu, path) chain = resolve(data.menu, path)
node = chain[-1] if chain else None node = chain[-1] if chain else None
await ws.send_json({ rendered = render(
"type": "html",
"path": path,
"html": render(
markdown, markdown,
path, path,
node.created if node else None, node.created if node else None,
node.modified 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), "has_h1": has_h1(markdown),
}) })
case "save": 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 as ``<div class="name">`` and closed by a matching ``:::`` (nest by
giving the outer container more colons, e.g. `::::`); the name may be giving the outer container more colons, e.g. `::::`); the name may be
followed by brace attributes (``::: aside {.right}``). ``::: aside`` followed by brace attributes (``::: aside {.right}``). ``::: aside``
floats as a side box beside the text and ``::: nocols`` opts its floats as a muted side box, dropping into the left margin on wide
section out of the column layout. A brace-attribute viewports — the same margin breakout ``{.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 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 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, 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 schemes stay visible; manually labelled links are untouched), and
``H~2~O`` / ``x^2^`` give sub/superscripts. ``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 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
dashes, ``...`` becomes an ellipsis, ``(c)`` becomes ©, and so on. Single dashes, ``...`` becomes an ellipsis, ``(c)`` becomes ©, and so on. Single
@@ -38,6 +51,7 @@ classes, e.g. `![alt](photo.avif "Caption"){.right}`.
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import NamedTuple
from markdown_it import MarkdownIt from markdown_it import MarkdownIt
from markdown_it.common.utils import escapeHtml from markdown_it.common.utils import escapeHtml
@@ -219,16 +233,23 @@ def _container_validate(params: str, _markup: str) -> bool:
return pos == len(rest) - 1 return pos == len(rest) - 1
def _container_render(self, tokens, idx, options, env): def _container_attrs(state) -> None:
"""Render `::: name {attrs}` containers as `<div class="name">`.""" """Apply `::: name {attrs}` classes to container tokens at parse time.
token = tokens[idx]
if token.nesting == 1: 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(" ") name, _, rest = token.info.strip().partition(" ")
token.attrJoin("class", name) token.attrJoin("class", name)
if rest.strip(): if rest.strip():
_, attrs = parse_attrs(rest.strip()) _, attrs = parse_attrs(rest.strip())
_apply_attrs(token, attrs) _apply_attrs(token, attrs)
return self.renderToken(tokens, idx, options, env)
def _block_attrs(state) -> None: def _block_attrs(state) -> None:
@@ -301,8 +322,7 @@ md = (
) )
.use(attrs_plugin) .use(attrs_plugin)
.use(admon_plugin) .use(admon_plugin)
.use(container_plugin, "block", validate=_container_validate, .use(container_plugin, "block", validate=_container_validate)
render=_container_render)
.use(footnote_plugin) .use(footnote_plugin)
.use(deflist_plugin) .use(deflist_plugin)
.use(tasklists_plugin, enabled=True) .use(tasklists_plugin, enabled=True)
@@ -316,28 +336,140 @@ md.add_render_rule("fence", _fence_rule)
md.options["alerts"] = True md.options["alerts"] = True
# Block attrs must be stripped before the typographer curlifies their quotes. # Block attrs must be stripped before the typographer curlifies their quotes.
md.core.ruler.before("replacements", "block_attrs", _block_attrs) 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("unwrap_lone_figures", _unwrap_lone_figures)
md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes) md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
md.core.ruler.push("shorten_autolinks", _shorten_autolinks) 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 break into the left margin (their
# negative-margin breakout only works as a direct .body child, never from
# 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( def render(
text: str, text: str,
page_path: str = "", page_path: str = "",
created: datetime | None = None, created: datetime | None = None,
modified: datetime | None = None, modified: datetime | None = None,
) -> str: ) -> Rendered:
"""Render Markdown text to an HTML string. """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 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,
e.g. the editor preview). Position is the author's choice — typically e.g. the editor preview). Position is the author's choice — typically
right after the article's h1. 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: if created is not None and "<p>{dates}</p>" in html:
html = html.replace("<p>{dates}</p>", _dateline(created, modified)) 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: 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: def page_content(menu: dict[str, Node], path: str) -> HTML:
"""Render the contents of the #main element for a page.""" """Render the contents of the #main element for a page."""
node = resolve(menu, path)[-1] 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: with doc:
# An h1 in the markdown owns the article heading; the title is # An h1 in the markdown owns the article heading; the title is
# only rendered as h1 when the markdown has none of its own. # only rendered as h1 when the markdown has none of its own.
if not has_h1(node.content or ""): if not has_h1(node.content or ""):
doc.h1(node.title) doc.h1(node.title)
doc.div( doc.div(HTML(rendered.html), class_="body")
HTML(render(node.content or "", path, node.created, node.modified)),
class_="body",
)
return HTML(str(doc)) return HTML(str(doc))