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 `
`` 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 ``
`."""
- 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))