From e0c1b37c0b79d2e27a01be5ce38cb6078290af56 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 18 Sep 2026 19:36:44 +0000 Subject: [PATCH] {cards}: server-rendered previews, immediate-children semantics, */** globs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/backend.md | 2 +- frontend/src/PageEditor.vue | 12 ++- frontend/src/assets/pagerite.css | 34 ++++---- pagerite/api.py | 26 ++++++- pagerite/markdown.py | 7 +- pagerite/views.py | 128 +++++++++++++++++-------------- 6 files changed, 120 insertions(+), 89 deletions(-) diff --git a/docs/backend.md b/docs/backend.md index 5f7560e..38e31a8 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 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.
+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 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 `
`, 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/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index 5400b02..1243da0 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -736,17 +736,15 @@ function previewIntoArticle(html, multicol) { if (!article) return // The server render owns the article completely — the injected title h1, // the column layout (.multicol on the article, the .colseg/.cols - // segments) — so the whole article content swaps as one. Only the edit - // pen and the category cards survive: detach them before innerHTML wipes - // them. pagerite.js re-places the pen into the first visible h1 on - // pagerite:preview. + // segments), the card stacks ({cards} tags expanded, or the children's + // cards appended when the page has no tag) — so the whole article + // content swaps as one. Only the edit pen survives: detach it before + // innerHTML wipes it. pagerite.js re-places the pen into the first + // visible h1 on pagerite:preview. article.classList.toggle('multicol', multicol) const pen = article.querySelector('button.edit-link') if (pen) pen.remove() - const cards = article.querySelector(':scope > .cards') - if (cards) cards.remove() article.innerHTML = html - if (cards) article.append(cards) runScripts(article) dispatchEvent(new CustomEvent('pagerite:preview')) } diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index 1f06f46..cfd45bf 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -481,19 +481,20 @@ main { container-type: inline-size; } -/* Child-entry card stacks: a category page (and the content-less category - 404) lists its published children after the markdown content — one column - per child, the child's whole subtree flattened into the column in menu - order (see _cards in views.py). The row bleeds to full page width - (div.wide): the columns first grow to fill it, then shrink rather than - wrap. Every card has the same fixed 16/10 shape, covered entirely by the +/* Child-entry cards: a category page (and the content-less category + 404) lists its published children after the markdown content — one + card per child (a child without a page of its own is represented by + its first leaf page; see _cards in views.py). The row bleeds to full + page width (div.wide): the cards first grow to fill it, then shrink + 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 placeholder when it has none) with the title overlaid on a translucent band at the bottom. The card is one holding only phrasing-level spans; the spans lay out as blocks. */ .cards { 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; gap: 1.25rem; margin-top: 2.5rem; @@ -502,18 +503,7 @@ main { padding-inline: 1.25rem; } -.cards .stack { - /* 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. +/* Phones: the cards stack vertically instead of shrinking to slivers. Text-only cards (gradient cover + description) then fit their content — the fixed 16/10 shape only makes sense for image covers. */ @media (max-width: 48rem) { @@ -527,12 +517,16 @@ main { } .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; display: flex; flex-direction: column; aspect-ratio: 16 / 10; /* 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; overflow: hidden; border: 1px solid var(--line); diff --git a/pagerite/api.py b/pagerite/api.py index 96865de..1e74926 100644 --- a/pagerite/api.py +++ b/pagerite/api.py @@ -19,6 +19,7 @@ from fastapi import ( WebSocket, WebSocketDisconnect, ) +from html5tagger import E from pydantic import BaseModel from pagerite import i18n, views @@ -495,6 +496,11 @@ async def editor_ws(ws: WebSocket) -> None: markdown = msg.get("markdown", "") chain = resolve(data.menu, path) 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( markdown, path, @@ -511,12 +517,30 @@ async def editor_ws(ws: WebSocket) -> None: if node 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( { "type": "html", "path": path, - "html": rendered.html, + "html": html, # Column-layout flag: the preview toggles the # article's .multicol class and swaps in the # segmented (.colseg/.cols) article html. diff --git a/pagerite/markdown.py b/pagerite/markdown.py index 3d2f70f..ddf0892 100644 --- a/pagerite/markdown.py +++ b/pagerite/markdown.py @@ -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 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. """ @@ -522,7 +522,7 @@ def _directives(state) -> None: 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 + 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 card markup never flows in columns). Runs on the render instance only — 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 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). + 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 right after the article's h1. """ diff --git a/pagerite/views.py b/pagerite/views.py index 1390fea..5f6969a 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -792,8 +792,9 @@ def page_content( A page with published children (a category page) lists them as cards 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 + which places card rows itself (bare: the children; with paths: + 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 title entries) fall back to the original. ``lang`` feeds the cards' per-target localization. @@ -815,7 +816,7 @@ 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); + # A {cards} tag places the card rows 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 @@ -846,6 +847,20 @@ def page_content( 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( doc, menu: dict[str, Node], @@ -856,49 +871,34 @@ def _cards( link_lang: str = "", lang: str = "", ) -> 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 - breakout): the columns grow to fill the page and shrink rather than - wrap. A column holds the child's whole subtree flattened in menu order - — nesting levels are not split out — starting with the first page that - has actual content (the child itself when it does, its first leaf - otherwise, recursively). Each card is one showing the page's share + One card per direct child, all in a single full-width row (the .wide + breakout): the cards grow to fill the page and shrink rather than + wrap. A child without a page of its own is represented by its first + leaf page (_represent, the nav-link logic). Each card is one + showing the page's share 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. Only phrasing-level elements (spans) go inside the : as a formatting element it would be cloned by the HTML parser around any block-level 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: return with doc.div(class_="cards wide"): - for slug, child in items: - cpath = f"{path}/{slug}" if path else slug - _card_stack(doc, data, cpath, child, translation, link_lang, lang) + for cpath, cnode in items: + _card(doc, data, cnode, cpath, 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 +#: A lone {cards} or {cards: ...} line in the markdown: card rows 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) @@ -917,47 +917,61 @@ def _cards_tag( _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. + page_content appends when the tag is absent — or, on the front page, + the other top-level pages (the front page is a top-level item itself, + not the parent of the others). Arguments are space-separated page + paths: a plain path renders that page alone (never its children), + ``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]] = [] 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: - items = [ - (f"{path}/{s}" if path else s, c) - for s, c in sorted_nodes(node.children) - if c.published - ] + if path: + children(path, node) + else: + for s, c in sorted_nodes(menu): + if c.published and s: + if r := _represent(c, s): + items.append(r) else: for spec in specs: 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("/") - 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 - ) + if chain := resolve(menu, base): + children(base, chain[-1]) elif chain := resolve(menu, spec): - items.append((spec, chain[-1])) + if r := _represent(chain[-1], spec): + items.append(r) 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) + _card(doc, data, cnode, cpath, translation, link_lang, lang) return str(doc) def _walk(node: Node, path: str): """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 - its descendants (content-less nodes contribute only their subtree).""" + node itself first when it has content, then its descendants + (content-less nodes contribute only their subtree).""" if node.chunks: yield path, node for slug, child in sorted_nodes(node.children): @@ -974,7 +988,7 @@ def _card( link_lang: str = "", lang: str = "", ) -> 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). The card text localizes per target article where that page is