From 2959f971bcc03d319fe69057d93541a38c203ff9 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 27 Aug 2026 00:08:51 +0000 Subject: [PATCH] Implement support for {} attrs on code blocks (line right after the closing fence) and containers (::: aside {...}). --- docs/design-principles.md | 4 +- frontend/src/assets/pagerite.css | 21 ++++++---- pagerite/markdown.py | 72 ++++++++++++++++++++++---------- 3 files changed, 67 insertions(+), 30 deletions(-) diff --git a/docs/design-principles.md b/docs/design-principles.md index 7dd2dbc..16d7305 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, 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. 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 (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. ## Page structure and navigation diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index 93ab9d0..f833883 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -922,16 +922,19 @@ figure:has(img[width]) { is not viewport-centered; each just overrides width/margin-inline, and later rules win at equal specificity. The analytics dashboard uses the same breakout directly on its container (div.wide — it is the page's - whole content, not a figure). */ + whole content, not a figure), and code blocks via a trailing {.wide} + line (fence attrs land on , hence pre:has(.wide)). */ figure:has(.wide), -div.wide { +div.wide, +pre:has(.wide) { width: 100vw; max-width: none; margin-inline: calc(50% - 50vw); } /* Full bleed means edge to edge — no rounded corners. */ -figure:has(.wide) img { +figure:has(.wide) img, +pre:has(.wide) { border-radius: 0; } @@ -939,21 +942,24 @@ figure:has(.wide) img { gutter), so the bleed anchors at the left gutter — the 1fr share of the 1fr + 4fr grid, i.e. 20vw — plus main's padding, and spans on to the right viewport edge. */ -body:has(.multicol) figure:has(.wide) { +body:has(.multicol) figure:has(.wide), +body:has(.multicol) pre:has(.wide) { margin-inline: calc(-20vw - 1.25rem) 0; } /* Editing: shrink the bleed to the space right of the docked editor. The window keeps its overlay scrollbars while editing, so — unlike a classic scrollbar — they take no layout space and the vw math stays exact. */ -body.editing figure:has(.wide) { +body.editing figure:has(.wide), +body.editing pre:has(.wide) { width: calc(100vw - var(--editor-w)); margin-inline: calc(50% - (100vw - var(--editor-w)) / 2); } /* Editing + multicol: the left gutter is 1/5 of the space right of the editor, and the bleed also crosses main's 1.25rem left padding. */ -body.editing:has(.multicol) figure:has(.wide) { +body.editing:has(.multicol) figure:has(.wide), +body.editing:has(.multicol) pre:has(.wide) { margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0; } @@ -965,7 +971,8 @@ body.editing:has(.multicol) figure:has(.wide) { entirely on pages without sub-navigation, and excluded while editing, where the editing rules above apply instead. */ @media (max-width: 102rem) { - body:has(#sidebar):not(.editing) figure:has(.wide) { + body:has(#sidebar):not(.editing) figure:has(.wide), + body:has(#sidebar):not(.editing) pre:has(.wide) { margin-inline: -13.25rem 0; } } diff --git a/pagerite/markdown.py b/pagerite/markdown.py index d03e90d..d6a18c2 100644 --- a/pagerite/markdown.py +++ b/pagerite/markdown.py @@ -9,12 +9,14 @@ note/tip/warning/etc., the title optional) and GitHub-style alerts (``> [!NOTE]`` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the 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. `::::`); ``::: aside`` +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 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. Bare URLs autolink (GFM), with +layout as a full-width element; written after a block (code fence, +heading, container, ...) it applies to that preceding block. Bare URLs autolink (GFM), with 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. @@ -121,11 +123,7 @@ def _unwrap_lone_figures(state) -> None: # A lone image becomes a
(see _image_rule); block # attrs on the paragraph (e.g. a trailing {.wide} line) move # onto the image so they survive the unwrap. - for key, value in (tokens[i - 1].attrs or {}).items(): - if key == "class": - child.attrJoin("class", value) - else: - child.attrSet(key, value) + _apply_attrs(child, tokens[i - 1].attrs or {}) tokens[i - 1].hidden = True tokens[i + 1].hidden = True @@ -168,14 +166,43 @@ def _shorten_autolinks(state) -> None: text.content = text.content.removeprefix("https://") -_CONTAINER_NAME_RE = re.compile(r"\s*[a-zA-Z][\w-]*\s*$") +_CONTAINER_NAME_RE = re.compile(r"[a-zA-Z][\w-]*") + + +def _apply_attrs(token, attrs: dict) -> None: + """Join/set parsed brace attributes (`{.class key=value}`) on a token.""" + for key, value in attrs.items(): + if key == "class": + token.attrJoin("class", value) + else: + token.attrSet(key, value) + + +def _container_validate(params: str, _markup: str) -> bool: + """`::: name`, optionally followed by brace attrs (`::: aside {.right}`).""" + name, _, rest = params.strip().partition(" ") + if not _CONTAINER_NAME_RE.fullmatch(name): + return False + rest = rest.strip() + if not rest: + return True + try: + pos, _ = parse_attrs(rest) + except ParseError: + return False + # parse() stops at (returns the index of) the closing brace. + return pos == len(rest) - 1 def _container_render(self, tokens, idx, options, env): - """Render `::: name` containers as `
`.""" + """Render `::: name {attrs}` containers as `
`.""" token = tokens[idx] if token.nesting == 1: - token.attrJoin("class", token.info.strip()) + 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) @@ -207,25 +234,28 @@ def _block_attrs(state) -> None: if not standalone and token.children[-2].type != "softbreak": continue # The target: the enclosing block for a trailing attrs line, or the - # previous same-level block for a standalone attrs paragraph. Never + # previous same-level block for a standalone attrs paragraph — + # including self-contained blocks like code fences and
. Never # a hidden token (tight-list paragraphs render no tag to hold the # attributes) — in that case leave the text untouched instead of # silently swallowing it. own = i - 1 # standalone: the attrs paragraph's own opening token j = i - 1 while j >= 0: - if (tokens[j].nesting == 1 and not tokens[j].hidden - and (not standalone or (j != own - and tokens[j].level == tokens[own].level))): + target = tokens[j] + if target.hidden: + pass + elif standalone: + if (j != own and target.level == tokens[own].level + and (target.nesting == 1 + or target.type in ("fence", "code_block", "hr"))): + break + elif target.nesting == 1: break j -= 1 if j < 0: continue - for key, value in attrs.items(): - if key == "class": - tokens[j].attrJoin("class", value) - else: - tokens[j].attrSet(key, value) + _apply_attrs(tokens[j], attrs) if standalone: tokens[own].hidden = True token.children = [] @@ -246,8 +276,8 @@ md = ( ) .use(attrs_plugin) .use(admon_plugin) - .use(container_plugin, "block", validate=lambda params, _markup: - bool(_CONTAINER_NAME_RE.fullmatch(params)), render=_container_render) + .use(container_plugin, "block", validate=_container_validate, + render=_container_render) .use(footnote_plugin) .use(deflist_plugin) .use(tasklists_plugin, enabled=True)