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:
+1
-1
@@ -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
@@ -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. `{.right}`.
|
classes, e.g. `{.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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user