{cards}: server-rendered previews, immediate-children semantics, */** globs

Preview: the editor render handler now returns the complete article HTML
({cards} tags expanded, children's cards appended when the page has no
tag) and the frontend swaps article.innerHTML wholesale — the old
detach/keep/re-append hack for .cards (which duplicated stacks) is gone.

Semantics: one card per item, no subtree flattening (stacks removed).
Bare {cards} lists immediate children (front page: the other top-level
items); a page-less item is represented by its first leaf page, the
nav-link logic. {cards: path} renders that page alone, path/* its
direct children, path/** all published descendants.
This commit is contained in:
2026-09-18 19:36:44 +00:00
parent b004fcd644
commit e0c1b37c0b
6 changed files with 120 additions and 89 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 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. 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 of a page that does not exist yet). 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, one card each (on the front page: the other top-level pages) — and `{cards: path path/* path/** ...}` (space-separated: a plain path renders that page alone, `path/*` its published children, `path/**` all published descendant pages; a page-less item is represented by its first leaf page, the nav-link logic) 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).
+5 -7
View File
@@ -736,17 +736,15 @@ function previewIntoArticle(html, multicol) {
if (!article) return if (!article) return
// The server render owns the article completely — the injected title h1, // The server render owns the article completely — the injected title h1,
// the column layout (.multicol on the article, the .colseg/.cols // the column layout (.multicol on the article, the .colseg/.cols
// segments) — so the whole article content swaps as one. Only the edit // segments), the card stacks ({cards} tags expanded, or the children's
// pen and the category cards survive: detach them before innerHTML wipes // cards appended when the page has no tag) — so the whole article
// them. pagerite.js re-places the pen into the first visible h1 on // content swaps as one. Only the edit pen survives: detach it before
// pagerite:preview. // innerHTML wipes it. pagerite.js re-places the pen into the first
// visible h1 on pagerite:preview.
article.classList.toggle('multicol', multicol) article.classList.toggle('multicol', multicol)
const pen = article.querySelector('button.edit-link') const pen = article.querySelector('button.edit-link')
if (pen) pen.remove() if (pen) pen.remove()
const cards = article.querySelector(':scope > .cards')
if (cards) cards.remove()
article.innerHTML = html article.innerHTML = html
if (cards) article.append(cards)
runScripts(article) runScripts(article)
dispatchEvent(new CustomEvent('pagerite:preview')) dispatchEvent(new CustomEvent('pagerite:preview'))
} }
+14 -20
View File
@@ -481,19 +481,20 @@ main {
container-type: inline-size; container-type: inline-size;
} }
/* Child-entry card stacks: a category page (and the content-less category /* Child-entry cards: a category page (and the content-less category
404) lists its published children after the markdown content — one column 404) lists its published children after the markdown content — one
per child, the child's whole subtree flattened into the column in menu card per child (a child without a page of its own is represented by
order (see _cards in views.py). The row bleeds to full page width its first leaf page; see _cards in views.py). The row bleeds to full
(div.wide): the columns first grow to fill it, then shrink rather than page width (div.wide): the cards first grow to fill it, then shrink
wrap. Every card has the same fixed 16/10 shape, covered entirely by the rather than wrap. Every card has the same fixed 16/10 shape, covered
entirely by the
page's share image (og:image heuristics, as a background — a gradient page's share image (og:image heuristics, as a background — a gradient
placeholder when it has none) with the title overlaid on a translucent placeholder when it has none) with the title overlaid on a translucent
band at the bottom. The card is one <a> holding only phrasing-level band at the bottom. The card is one <a> holding only phrasing-level
spans; the spans lay out as blocks. */ spans; the spans lay out as blocks. */
.cards { .cards {
display: flex; display: flex;
/* Stacks stop growing at their cap; center the row in the bleed then. */ /* Cards stop growing at their cap; center the row in the bleed then. */
justify-content: center; justify-content: center;
gap: 1.25rem; gap: 1.25rem;
margin-top: 2.5rem; margin-top: 2.5rem;
@@ -502,18 +503,7 @@ main {
padding-inline: 1.25rem; padding-inline: 1.25rem;
} }
.cards .stack { /* Phones: the cards stack vertically instead of shrinking to slivers.
/* Grow to fill the row (up to the cap — full-width rows would make huge
cards), shrink (not wrap) when there are too many. */
flex: 1 1 0;
max-width: 24rem;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Phones: the columns stack vertically instead of shrinking to slivers.
Text-only cards (gradient cover + description) then fit their content — Text-only cards (gradient cover + description) then fit their content —
the fixed 16/10 shape only makes sense for image covers. */ the fixed 16/10 shape only makes sense for image covers. */
@media (max-width: 48rem) { @media (max-width: 48rem) {
@@ -527,12 +517,16 @@ main {
} }
.card { .card {
/* Grow to fill the row (up to the cap — full-width rows would make huge
cards), shrink (not wrap) when there are too many. */
flex: 1 1 0;
max-width: 24rem;
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
/* Allow shrinking below the text's min-content width: without this the /* Allow shrinking below the text's min-content width: without this the
text cards hold their stack wider than the image-only stacks. */ text cards hold their row wider than the image-only cards. */
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
+25 -1
View File
@@ -19,6 +19,7 @@ from fastapi import (
WebSocket, WebSocket,
WebSocketDisconnect, WebSocketDisconnect,
) )
from html5tagger import E
from pydantic import BaseModel from pydantic import BaseModel
from pagerite import i18n, views from pagerite import i18n, views
@@ -495,6 +496,11 @@ async def editor_ws(ws: WebSocket) -> None:
markdown = msg.get("markdown", "") markdown = msg.get("markdown", "")
chain = resolve(data.menu, path) chain = resolve(data.menu, path)
node = chain[-1] if chain else None node = chain[-1] if chain else None
# Expand {cards} like page_content does, so the preview
# shows real cards, not the literal tag. No translation
# context: the preview has no lang of its own, so cards
# render in their originals.
has_cards_tag = views._CARDS_TAG_RE.search(markdown) is not None
rendered = render( rendered = render(
markdown, markdown,
path, path,
@@ -511,12 +517,30 @@ async def editor_ws(ws: WebSocket) -> None:
if node if node
else None else None
), ),
directives=(
{
"cards": lambda args, _env: views._cards_tag(
data.menu, data, node, path, args
)
}
if node is not None and has_cards_tag
else None
),
) )
html = rendered.html
if node is not None and not has_cards_tag:
# Without a {cards} tag page_content appends the
# children's cards after the content — the preview
# replaces the whole article, so include them here.
doc = E.div
with doc:
views._cards(doc, data.menu, data, node, path)
html += str(doc)
await ws.send_json( await ws.send_json(
{ {
"type": "html", "type": "html",
"path": path, "path": path,
"html": rendered.html, "html": html,
# Column-layout flag: the preview toggles the # Column-layout flag: the preview toggles the
# article's .multicol class and swaps in the # article's .multicol class and swaps in the
# segmented (.colseg/.cols) article html. # segmented (.colseg/.cols) article html.
+4 -3
View File
@@ -59,7 +59,7 @@ classes, e.g. `![alt](photo.avif "Caption"){.right}`.
A lone `{name}` or `{name: args}` line is a block directive, expanded by A lone `{name}` or `{name: args}` line is a block directive, expanded by
the caller through render(directives=...) — `{dates}` (built in) expands the caller through render(directives=...) — `{dates}` (built in) expands
to the article's dateline, `{cards}` / `{cards: path ...}` to card stacks to the article's dateline, `{cards}` / `{cards: path ...}` to card rows
of other pages (views.py). Unresolved tags render as the literal source. of other pages (views.py). Unresolved tags render as the literal source.
""" """
@@ -522,7 +522,7 @@ def _directives(state) -> None:
The expansion is not markdown.py's business: _directive_rule delegates The expansion is not markdown.py's business: _directive_rule delegates
to the resolvers render() put in env["directives"], falling back to the 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 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 preview of a page that does not exist yet). The ``cards`` directive gets .wide so it
stands alone as a full-width block outside the column segments (the stands alone as a full-width block outside the column segments (the
card markup never flows in columns). Runs on the render instance only — card markup never flows in columns). Runs on the render instance only —
the verbatim parser keeps the plain paragraph so segments/chunks see the verbatim parser keeps the plain paragraph so segments/chunks see
@@ -754,7 +754,8 @@ def render(
``{name}`` or ``{name: args}`` line — are expanded by the resolvers ``{name}`` or ``{name: args}`` line — are expanded by the resolvers
passed as ``directives`` (name → (args, env) → HTML or None), with passed as ``directives`` (name → (args, env) → HTML or None), with
``dates`` built in when ``created`` is given; unresolved tags render as ``dates`` built in when ``created`` is given; unresolved tags render as
the literal source (e.g. in the editor preview without page data). the literal source (e.g. in the editor preview of a not-yet-created
page).
Position is the author's choice — the dateline typically goes Position is the author's choice — the dateline typically goes
right after the article's h1. right after the article's h1.
""" """
+71 -57
View File
@@ -792,8 +792,9 @@ def page_content(
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 — unless the content has a ``{cards}`` tag, after the markdown content — unless the content has a ``{cards}`` tag,
which places card stacks itself (bare: the children; with paths: which places card rows itself (bare: the children; with paths:
those pages / ``path/*`` their children), one row per tag. With a translation, its Markdown goes those pages, ``path/*`` their children, ``path/**`` all descendants),
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.
@@ -815,7 +816,7 @@ 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); # A {cards} tag places the card rows itself (possibly several);
# without one the children are appended after the content as before. # without one the children are appended after the content as before.
has_cards_tag = _CARDS_TAG_RE.search(content) is not None has_cards_tag = _CARDS_TAG_RE.search(content) is not None
directives = None directives = None
@@ -846,6 +847,20 @@ def page_content(
return HTML(str(doc)) return HTML(str(doc))
def _represent(node: Node, path: str) -> tuple[str, Node] | None:
"""The (path, node) a card for this menu item points at: the item
itself when it has a page, else its first published leaf page,
recursively — the same logic as nav links (first_leaf)."""
if node.chunks:
return path, node
for slug, child in sorted_nodes(node.children):
if child.published:
cpath = f"{path}/{slug}" if path else slug
if r := _represent(child, cpath):
return r
return None
def _cards( def _cards(
doc, doc,
menu: dict[str, Node], menu: dict[str, Node],
@@ -856,49 +871,34 @@ def _cards(
link_lang: str = "", link_lang: str = "",
lang: str = "", lang: str = "",
) -> None: ) -> None:
"""Card stacks of the node's published children (nothing when childless). """Cards of the node's published children (nothing when childless).
One column per direct child, all in a single full-width row (the .wide One card per direct child, all in a single full-width row (the .wide
breakout): the columns grow to fill the page and shrink rather than breakout): the cards grow to fill the page and shrink rather than
wrap. A column holds the child's whole subtree flattened in menu order wrap. A child without a page of its own is represented by its first
— nesting levels are not split out — starting with the first page that leaf page (_represent, the nav-link logic). Each card is one <a>
has actual content (the child itself when it does, its first leaf showing the page's share
otherwise, recursively). Each card is one <a> showing the page's share
image (the same heuristics as og:image) as the cover and its title; image (the same heuristics as og:image) as the cover and its title;
image-less cards get a gradient cover and also show the description. image-less cards get a gradient cover and also show the description.
Only phrasing-level elements (spans) go inside the <a>: as a formatting Only phrasing-level elements (spans) go inside the <a>: as a formatting
element it would be cloned by the HTML parser around any block-level element it would be cloned by the HTML parser around any block-level
child, splitting one card into several links. child, splitting one card into several links.
""" """
items = [(s, c) for s, c in sorted_nodes(node.children) if c.published] items = [
r
for s, c in sorted_nodes(node.children)
if c.published
for r in [_represent(c, f"{path}/{s}" if path else s)]
if r
]
if not items: if not items:
return return
with doc.div(class_="cards wide"): with doc.div(class_="cards wide"):
for slug, child in items: for cpath, cnode in items:
cpath = f"{path}/{slug}" if path else slug _card(doc, data, cnode, cpath, translation, link_lang, lang)
_card_stack(doc, data, cpath, child, translation, link_lang, lang)
def _card_stack( #: A lone {cards} or {cards: ...} line in the markdown: card rows placed
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. #: by the author. Any such tag suppresses the automatic end-of-page cards.
_CARDS_TAG_RE = re.compile(r"^\{cards(?::[^{}\n]*)?\}[ \t]*$", re.M) _CARDS_TAG_RE = re.compile(r"^\{cards(?::[^{}\n]*)?\}[ \t]*$", re.M)
@@ -917,47 +917,61 @@ def _cards_tag(
_cards, so author-placed cards look like category cards). _cards, so author-placed cards look like category cards).
A bare ``{cards}`` lists the page's own published children — what A bare ``{cards}`` lists the page's own published children — what
page_content appends when the tag is absent. Arguments are page_content appends when the tag is absent — or, on the front page,
space-separated page paths: a path contributes its subtree (one stack, the other top-level pages (the front page is a top-level item itself,
flattened like a category child), and ``path/*`` its published not the parent of the others). Arguments are space-separated page
children (one stack each). Unresolvable paths are skipped; a tag that paths: a plain path renders that page alone (never its children),
ends up with nothing renders as nothing. ``path/*`` its published children and ``path/**`` all published
descendant pages. One card per item; a page-less item is represented
by its first leaf page (_represent). Unresolvable paths are skipped;
a tag that ends up with nothing renders as nothing.
""" """
items: list[tuple[str, Node]] = [] items: list[tuple[str, Node]] = []
specs = args.split() specs = args.split()
def children(base: str, parent: Node):
for s, c in sorted_nodes(parent.children):
if c.published:
if r := _represent(c, f"{base}/{s}" if base else s):
items.append(r)
if not specs: if not specs:
items = [ if path:
(f"{path}/{s}" if path else s, c) children(path, node)
for s, c in sorted_nodes(node.children) else:
if c.published for s, c in sorted_nodes(menu):
] if c.published and s:
if r := _represent(c, s):
items.append(r)
else: else:
for spec in specs: for spec in specs:
spec = spec.strip("/") spec = spec.strip("/")
if spec.endswith("/*"): if spec.endswith("/**"):
base = spec[:-3].rstrip("/")
if chain := resolve(menu, base):
for s, c in sorted_nodes(chain[-1].children):
if c.published:
items.extend(_walk(c, f"{base}/{s}" if base else s))
elif spec.endswith("/*"):
base = spec[:-2].rstrip("/") base = spec[:-2].rstrip("/")
chain = resolve(menu, base) if chain := resolve(menu, base):
if chain: children(base, chain[-1])
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): elif chain := resolve(menu, spec):
items.append((spec, chain[-1])) if r := _represent(chain[-1], spec):
items.append(r)
if not items: if not items:
return "" return ""
doc = E.div(class_="cards wide") doc = E.div(class_="cards wide")
with doc: with doc:
for cpath, cnode in items: for cpath, cnode in items:
_card_stack(doc, data, cpath, cnode, translation, link_lang, lang) _card(doc, data, cnode, cpath, translation, link_lang, lang)
return str(doc) return str(doc)
def _walk(node: Node, path: str): def _walk(node: Node, path: str):
"""Published content pages of a subtree, pre-order in menu order: the """Published content pages of a subtree, pre-order in menu order: the
node itself first when it has content (the stack's landing card), then node itself first when it has content, then its descendants
its descendants (content-less nodes contribute only their subtree).""" (content-less nodes contribute only their subtree)."""
if node.chunks: if node.chunks:
yield path, node yield path, node
for slug, child in sorted_nodes(node.children): for slug, child in sorted_nodes(node.children):
@@ -974,7 +988,7 @@ def _card(
link_lang: str = "", link_lang: str = "",
lang: str = "", lang: str = "",
) -> None: ) -> None:
"""One card in a stack: cover + title, plus the description when the """One card: cover + title, plus the description when the
page has no image (its card shows a gradient cover instead). page has no image (its card shows a gradient cover instead).
The card text localizes per target article where that page is The card text localizes per target article where that page is