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)

This commit is contained in:
2026-09-18 12:38:21 +00:00
parent 02396f4482
commit 626dc1df8f
3 changed files with 186 additions and 16 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full
## `markdown.py` ## `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 `<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). 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 `<pre>`, the `<code>` 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 `<img>` 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 `<pre>`, the `<code>` 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 `<article>` children) — h1/h2 headings and `.wide` blocks stand bare, the runs between them become `<div class="colseg">` (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). `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 `<article>` children) — h1/h2 headings and `.wide` blocks stand bare, the runs between them become `<div class="colseg">` (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).
+78 -5
View File
@@ -56,9 +56,15 @@ becomes a block `<figure>` — with `<figcaption>` when it has a title.
Images inline with other content stay plain inline `<img>`, as does raw Images inline with other content stay plain inline `<img>`, as does raw
`<img>` HTML written by the author. Positioning is done with attribute `<img>` HTML written by the author. Positioning is done with attribute
classes, e.g. `![alt](photo.avif "Caption"){.right}`. 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 import re
from collections.abc import Callable
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import NamedTuple 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"<p>{escapeHtml(token.content)}</p>\n"
return html + "\n"
def make_md(*, verbatim: bool = False) -> MarkdownIt: def make_md(*, verbatim: bool = False) -> MarkdownIt:
"""A fully configured parser. The module-level ``md`` (below) is the """A fully configured parser. The module-level ``md`` (below) is the
render instance; ``verbatim=True`` builds the segmentation instance for 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("image", _image_rule)
parser.add_render_rule("fence", _fence_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. # GFM alerts (`> [!NOTE]` etc.), built into markdown-it-py's blockquote rule.
parser.options["alerts"] = True parser.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.
@@ -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("tag_task_checkboxes", _tag_task_checkboxes)
parser.core.ruler.push("shorten_autolinks", _shorten_autolinks) parser.core.ruler.push("shorten_autolinks", _shorten_autolinks)
parser.core.ruler.push("heading_ids", _heading_ids) parser.core.ruler.push("heading_ids", _heading_ids)
if not verbatim:
parser.core.ruler.push("directives", _directives)
return parser return parser
@@ -661,6 +728,7 @@ def render(
modified: datetime | None = None, modified: datetime | None = None,
title: str | None = None, title: str | None = None,
anchors_from: tuple[str, str] | None = None, anchors_from: tuple[str, str] | None = None,
directives: dict[str, Callable[[str, dict], str | None]] | None = None,
) -> Rendered: ) -> Rendered:
"""Render Markdown text to the article body's HTML and layout flags. """Render Markdown text to the article body's HTML and layout flags.
@@ -682,11 +750,18 @@ def render(
classes. 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``). Block directives in general — a lone
e.g. the editor preview). Position is the author's choice — typically ``{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. 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: if anchors_from is not None:
env["anchor_ids"] = anchor_ids(*anchors_from) env["anchor_ids"] = anchor_ids(*anchors_from)
if title and not has_h1(text): if title and not has_h1(text):
@@ -732,8 +807,6 @@ def render(
html = marked html = marked
parts.append(f'<div class="colseg{cols}">{html}</div>') parts.append(f'<div class="colseg{cols}">{html}</div>')
html = "".join(parts) html = "".join(parts)
if created is not None and "<p>{dates}</p>" in html:
html = html.replace("<p>{dates}</p>", _dateline(created, modified))
return Rendered(html, total > MULTICOL_TEXT) return Rendered(html, total > MULTICOL_TEXT)
+107 -10
View File
@@ -791,7 +791,9 @@ def page_content(
"""Render the contents of the #main element for a page. """Render the contents of the #main element for a page.
A page with published children (a category page) lists them as cards 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 through the same render pipeline; missing pieces (markdown=None, absent
title entries) fall back to the original. ``lang`` feeds the cards' title entries) fall back to the original. ``lang`` feeds the cards'
per-target localization. per-target localization.
@@ -813,8 +815,24 @@ def page_content(
) )
# The title is injected into the markdown (as # title when it has no # 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. # 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( 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 # Long articles get .multicol: the article column cap lifts (see the
# #content grid in pagerite.css) and the .cols segments lay out in at # #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 doc = E.article(class_="multicol") if rendered.multicol else E.article
with doc: with doc:
doc(HTML(rendered.html)) 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)) return HTML(str(doc))
@@ -857,12 +876,82 @@ def _cards(
with doc.div(class_="cards wide"): with doc.div(class_="cards wide"):
for slug, child in items: for slug, child in items:
cpath = f"{path}/{slug}" if path else slug cpath = f"{path}/{slug}" if path else slug
entries = list(_walk(child, cpath)) _card_stack(doc, data, cpath, child, translation, link_lang, lang)
if not entries:
continue
with doc.div(class_="stack"): def _card_stack(
for epath, enode in entries: doc,
_card(doc, data, enode, epath, translation, link_lang, lang) 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): def _walk(node: Node, path: str):
@@ -898,7 +987,15 @@ def _card(
md = node_markdown(data, node) or "" md = node_markdown(data, node) or ""
if lang and lang in node.langs: if lang and lang in node.langs:
md = i18n.hybrid_markdown(data, node, path, lang) 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) image, _ = _media(html)
if not image: if not image:
description = _description(html, 150) description = _description(html, 150)