From 626dc1df8fdf641d6452f393d8f7910d41239de7 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 18 Sep 2026 12:38:21 +0000 Subject: [PATCH] Block directives: lone {name}/{name: args} lines expanded via render(directives=...); {dates} rebuilt on it, new {cards} / {cards: path path/* ...} for author-placed card stacks (suppresses the auto end-of-page cards) --- docs/backend.md | 2 +- pagerite/markdown.py | 83 ++++++++++++++++++++++++++++-- pagerite/views.py | 117 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 186 insertions(+), 16 deletions(-) diff --git a/docs/backend.md b/docs/backend.md index b55eabb..5f7560e 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -26,7 +26,7 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full ## `markdown.py` -markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `` 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). Code fences take pandoc-style brace attributes on the info line (` ```{.python .wide #id key=val} ` — the first class is the language when no bare language word precedes the braces) as well as a trailing `{...}` line; both land on the `
`, the `` keeps only the language class.
+markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `` HTML stay plain. A lone `{name}` / `{name: args}` line is a block directive: a core rule turns it into a `directive` token (render instance only — the verbatim parser keeps the plain paragraph so segments/chunks see the placeholder source), and the render rule delegates to the resolvers passed as `render(directives=...)`, leaving the source literal where no resolver applies (e.g. the editor preview). Built in: `{dates}` expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`, registered by `render()` when `created` is given); views.py resolves `{cards}` — the page's published children — and `{cards: path path/* ...}` (space-separated: a path's subtree as one stack, `path/*` its children as one stack each) into the same card-row markup as category pages (`.cards.wide`, a boundary block outside the column segments). A page with any `{cards}` tag drops the automatic end-of-page child cards; multiple tags each render their own row. Code fences take pandoc-style brace attributes on the info line (` ```{.python .wide #id key=val} ` — the first class is the language when no bare language word precedes the braces) as well as a trailing `{...}` line; both land on the `
`, the `` keeps only the language class.
 
 `render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `
` children) — h1/h2 headings and `.wide` blocks stand bare, the runs between them become `
` (margin-breakout boxes — `.margin`, `::: aside` — stay inside the segment at their anchor point; the CSS positions them out of flow into the side zone) (plus `.cols` on segments with enough text in at least two paragraphs or one long enough to split across columns, `::: nocols` opting out; in column segments, paragraphs past `BREAKABLE_TEXT` visible characters are marked `.breakable` so they may split across columns), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation). diff --git a/pagerite/markdown.py b/pagerite/markdown.py index af56d6e..3d2f70f 100644 --- a/pagerite/markdown.py +++ b/pagerite/markdown.py @@ -56,9 +56,15 @@ becomes a block `
` — with `
` when it has a title. Images inline with other content stay plain inline ``, as does raw `` HTML written by the author. Positioning is done with attribute classes, e.g. `![alt](photo.avif "Caption"){.right}`. + +A lone `{name}` or `{name: args}` line is a block directive, expanded by +the caller through render(directives=...) — `{dates}` (built in) expands +to the article's dateline, `{cards}` / `{cards: path ...}` to card stacks +of other pages (views.py). Unresolved tags render as the literal source. """ import re +from collections.abc import Callable from datetime import datetime, timedelta from typing import NamedTuple @@ -505,6 +511,64 @@ def anchor_ids(text: str, title: str | None = None) -> list[str]: ] +#: A lone {...} paragraph: a block directive like {dates} or +#: {cards: docs/* news} — name, then optional ":"-separated argument text. +_DIRECTIVE_RE = re.compile(r"\{([a-z][a-z0-9_-]*)(?::([^{}\n]*))?\}") + + +def _directives(state) -> None: + """Turn lone ``{name}`` / ``{name: args}`` paragraphs into directive tokens. + + The expansion is not markdown.py's business: _directive_rule delegates + to the resolvers render() put in env["directives"], falling back to the + literal source when the tag is unknown in the context (e.g. the editor + preview without page data). The ``cards`` directive gets .wide so it + stands alone as a full-width block outside the column segments (the + card markup never flows in columns). Runs on the render instance only — + the verbatim parser keeps the plain paragraph so segments/chunks see + the placeholder source. + """ + tokens = state.tokens + out = [] + i = 0 + while i < len(tokens): + if ( + i + 2 < len(tokens) + and tokens[i].type == "paragraph_open" + and tokens[i + 1].type == "inline" + and tokens[i + 2].type == "paragraph_close" + ): + inline = tokens[i + 1] + children = inline.children or [] + if len(children) == 1 and children[0].type == "text": + m = _DIRECTIVE_RE.fullmatch(children[0].content.strip()) + if m: + token = Token("directive", "", 0) + token.level = tokens[i].level + token.map = tokens[i].map + token.content = m.group(0) + token.meta = {"name": m.group(1), "args": (m.group(2) or "").strip()} + if m.group(1) == "cards": + token.attrSet("class", "wide") + out.append(token) + i += 3 + continue + out.append(tokens[i]) + i += 1 + state.tokens = out + + +def _directive_rule(self: RendererHTML, tokens, idx: int, options, env: dict) -> str: + """Render a directive token via env["directives"][name](args, env); + unresolved tags render as the literal source paragraph.""" + token = tokens[idx] + resolver = (env.get("directives") or {}).get(token.meta["name"]) + html = resolver(token.meta["args"], env) if resolver else None + if html is None: + return f"

{escapeHtml(token.content)}

\n" + return html + "\n" + + def make_md(*, verbatim: bool = False) -> MarkdownIt: """A fully configured parser. The module-level ``md`` (below) is the render instance; ``verbatim=True`` builds the segmentation instance for @@ -539,6 +603,7 @@ def make_md(*, verbatim: bool = False) -> MarkdownIt: ) parser.add_render_rule("image", _image_rule) parser.add_render_rule("fence", _fence_rule) + parser.add_render_rule("directive", _directive_rule) # GFM alerts (`> [!NOTE]` etc.), built into markdown-it-py's blockquote rule. parser.options["alerts"] = True # Block attrs must be stripped before the typographer curlifies their quotes. @@ -548,6 +613,8 @@ def make_md(*, verbatim: bool = False) -> MarkdownIt: parser.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes) parser.core.ruler.push("shorten_autolinks", _shorten_autolinks) parser.core.ruler.push("heading_ids", _heading_ids) + if not verbatim: + parser.core.ruler.push("directives", _directives) return parser @@ -661,6 +728,7 @@ def render( modified: datetime | None = None, title: str | None = None, anchors_from: tuple[str, str] | None = None, + directives: dict[str, Callable[[str, dict], str | None]] | None = None, ) -> Rendered: """Render Markdown text to the article body's HTML and layout flags. @@ -682,11 +750,18 @@ def render( 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 + (needs ``created``/``modified``). Block directives in general — a lone + ``{name}`` or ``{name: args}`` line — are expanded by the resolvers + passed as ``directives`` (name → (args, env) → HTML or None), with + ``dates`` built in when ``created`` is given; unresolved tags render as + the literal source (e.g. in the editor preview without page data). + Position is the author's choice — the dateline typically goes right after the article's h1. """ - env = {"page_path": page_path, "line_offset": 0} + directives = dict(directives or {}) + if created is not None: + directives.setdefault("dates", lambda _args, _env: _dateline(created, modified)) + env = {"page_path": page_path, "line_offset": 0, "directives": directives} if anchors_from is not None: env["anchor_ids"] = anchor_ids(*anchors_from) if title and not has_h1(text): @@ -732,8 +807,6 @@ def render( html = marked parts.append(f'
{html}
') html = "".join(parts) - if created is not None and "

{dates}

" in html: - html = html.replace("

{dates}

", _dateline(created, modified)) return Rendered(html, total > MULTICOL_TEXT) diff --git a/pagerite/views.py b/pagerite/views.py index 68d6fda..1390fea 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -791,7 +791,9 @@ def page_content( """Render the contents of the #main element for a page. A page with published children (a category page) lists them as cards - after the markdown content. With a translation, its Markdown goes + after the markdown content — unless the content has a ``{cards}`` tag, + which places card stacks itself (bare: the children; with paths: + those pages / ``path/*`` their children), one row per tag. With a translation, its Markdown goes through the same render pipeline; missing pieces (markdown=None, absent title entries) fall back to the original. ``lang`` feeds the cards' per-target localization. @@ -813,8 +815,24 @@ def page_content( ) # The title is injected into the markdown (as # title when it has no # h1 of its own), so title and content render as one article. + # A {cards} tag places the card stacks itself (possibly several); + # without one the children are appended after the content as before. + has_cards_tag = _CARDS_TAG_RE.search(content) is not None + directives = None + if has_cards_tag: + directives = { + "cards": lambda args, _env: _cards_tag( + menu, data, node, path, args, translation, link_lang, lang + ) + } rendered = render( - content, path, node.created, node.modified, title=title, anchors_from=anchors_from + content, + path, + node.created, + node.modified, + title=title, + anchors_from=anchors_from, + directives=directives, ) # Long articles get .multicol: the article column cap lifts (see the # #content grid in pagerite.css) and the .cols segments lay out in at @@ -823,7 +841,8 @@ def page_content( doc = E.article(class_="multicol") if rendered.multicol else E.article with doc: doc(HTML(rendered.html)) - _cards(doc, menu, data, node, path, translation, link_lang, lang) + if not has_cards_tag: + _cards(doc, menu, data, node, path, translation, link_lang, lang) return HTML(str(doc)) @@ -857,12 +876,82 @@ def _cards( with doc.div(class_="cards wide"): for slug, child in items: cpath = f"{path}/{slug}" if path else slug - entries = list(_walk(child, cpath)) - if not entries: - continue - with doc.div(class_="stack"): - for epath, enode in entries: - _card(doc, data, enode, epath, translation, link_lang, lang) + _card_stack(doc, data, cpath, child, translation, link_lang, lang) + + +def _card_stack( + doc, + data: Data, + path: str, + node: Node, + translation: Translation | None = None, + link_lang: str = "", + lang: str = "", +) -> None: + """One stack column: the subtree of ``node`` flattened in menu order + (see _cards).""" + entries = list(_walk(node, path)) + if not entries: + return + with doc.div(class_="stack"): + for epath, enode in entries: + _card(doc, data, enode, epath, translation, link_lang, lang) + + +#: A lone {cards} or {cards: ...} line in the markdown: card stacks placed +#: by the author. Any such tag suppresses the automatic end-of-page cards. +_CARDS_TAG_RE = re.compile(r"^\{cards(?::[^{}\n]*)?\}[ \t]*$", re.M) + + +def _cards_tag( + menu: dict[str, Node], + data: Data, + node: Node, + path: str, + args: str, + translation: Translation | None = None, + link_lang: str = "", + lang: str = "", +) -> str: + """Expand a ``{cards}`` directive to a card row (the same markup as + _cards, so author-placed cards look like category cards). + + A bare ``{cards}`` lists the page's own published children — what + page_content appends when the tag is absent. Arguments are + space-separated page paths: a path contributes its subtree (one stack, + flattened like a category child), and ``path/*`` its published + children (one stack each). Unresolvable paths are skipped; a tag that + ends up with nothing renders as nothing. + """ + items: list[tuple[str, Node]] = [] + specs = args.split() + if not specs: + items = [ + (f"{path}/{s}" if path else s, c) + for s, c in sorted_nodes(node.children) + if c.published + ] + else: + for spec in specs: + spec = spec.strip("/") + if spec.endswith("/*"): + base = spec[:-2].rstrip("/") + chain = resolve(menu, base) + if chain: + items.extend( + (f"{base}/{s}" if base else s, c) + for s, c in sorted_nodes(chain[-1].children) + if c.published + ) + elif chain := resolve(menu, spec): + items.append((spec, chain[-1])) + if not items: + return "" + doc = E.div(class_="cards wide") + with doc: + for cpath, cnode in items: + _card_stack(doc, data, cpath, cnode, translation, link_lang, lang) + return str(doc) def _walk(node: Node, path: str): @@ -898,7 +987,15 @@ def _card( md = node_markdown(data, node) or "" if lang and lang in node.langs: md = i18n.hybrid_markdown(data, node, path, lang) - html = render(md, path, node.created, node.modified).html + html = render( + md, + path, + node.created, + node.modified, + # Card heuristics only mine the prose: nested {cards} rows + # would just be noise in the description extraction. + directives={"cards": lambda _args, _env: ""}, + ).html image, _ = _media(html) if not image: description = _description(html, 150)