diff --git a/docs/backend.md b/docs/backend.md index 3fea8db..78f84a5 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -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 `` 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 `
` (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. diff --git a/docs/design-principles.md b/docs/design-principles.md index 16d7305..7dadcd4 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -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 `
` 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 `
` — 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 `
` — with `
` when it has a title; images inline with text and raw `` 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 `
` 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 `
` — 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 `
` — with `
` when it has a title; images inline with text and raw `` 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 @@ -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 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. ## Styling diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index 3b4eb3d..f026636 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -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 diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index bbf7363..3950570 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -275,12 +275,13 @@ 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 and scrap the right + gutter: a 1fr left gutter (which holds the overlaying sidebar and any + margin-breakout boxes) and the article taking all the rest, out to the + right viewport edge. Columns are capped at two (see the + `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 { grid-template-columns: minmax(0, 1fr) minmax(0, 4fr); } @@ -561,20 +562,28 @@ 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. */ +/* Multi-column reading, but only for long articles: the backend render + splits the body into .colseg segments (separated by full-width h2s and + .wide elements), tags segments with enough text as .cols (a ::: nocols + container opts its section out) and flags the article .multicol based + on content length — code blocks excluded. Never more than two columns: + `columns: 30rem 2` fits one or two columns of at least 30rem into the + article's current width — since .multicol also uncaps the article + width (see #content above), a wider window widens the two columns + instead of adding more. */ .multicol .colseg.cols { - columns: 30rem; + columns: 30rem 2; column-gap: 3.5rem; 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 { margin-bottom: 1rem; @@ -725,13 +734,13 @@ 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: right); {.margin} / ::: margin is a plainer margin note, and + figures take {.margin} like {.left}. All of them drop into the left + margin when the layout has room for it (the breakout rules live with + the figure rules below); without room they stay in-column floats — + asides on the right, margin boxes on the left. Headings already clear + floats, so boxes never bleed into the next section. */ .aside { float: right; clear: right; @@ -745,15 +754,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 { @@ -917,6 +930,52 @@ figure:has(img[width]) { 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 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 @@ -1038,9 +1097,9 @@ article h2 { /* 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. */ + floated figures — .left/.right/.margin fall back to plain centered + figures (explicit img widths still shrink-wrap), while .wide keeps its + full viewport bleed. */ @media (max-width: 48rem) { /* Nav type shrinks fluidly as space runs out. The nav font-size is @@ -1105,7 +1164,8 @@ article h2 { } figure:has(.right), - figure:has(.left) { + figure:has(.left), + figure:has(.margin) { float: none; width: 100%; max-width: none; diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index 2169b70..10c9452 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -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", diff --git a/pagerite/app.py b/pagerite/app.py index 5063e8c..a20ef32 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -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 + 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": render( - markdown, - path, - node.created if node else None, - node.modified if node else None, - ), + "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": diff --git a/pagerite/markdown.py b/pagerite/markdown.py index f88a583..2f1f422 100644 --- a/pagerite/markdown.py +++ b/pagerite/markdown.py @@ -11,8 +11,10 @@ same callout styling). ``::: name`` opens a generic container rendered as ``
`` 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, dropping into the left margin on wide +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 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 ``
`` (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 `
`.""" - 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"", 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
) 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
. + 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'
{html}
') + html = "".join(parts) if created is not None and "

{dates}

" in html: html = html.replace("

{dates}

", _dateline(created, modified)) - return html + return Rendered(html, total > MULTICOL_TEXT) def _dateline(created: datetime, modified: datetime | None) -> str: diff --git a/pagerite/views.py b/pagerite/views.py index 2ec9073..86720f2 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -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))