Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
825379ca2a | ||
|
|
ddf323bf41 | ||
|
|
f708e1dbca |
@@ -18,6 +18,8 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full
|
|||||||
|
|
||||||
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). 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).
|
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). 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).
|
||||||
|
|
||||||
|
`render()` returns a `Rendered(html, multicol)`: the body segmented for the column layout — h1/h2 headings, `.wide` blocks and margin-breakout blocks (`.margin`, `::: aside`) stand bare, the runs between them become `<div class="colseg">` (plus `.cols` on segments with enough text, `::: nocols` opting out), 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).
|
||||||
|
|
||||||
## `views.py`
|
## `views.py`
|
||||||
|
|
||||||
The shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by `Node.order`; nav links to content-less labels point at their first child via `first_leaf`, the first published descendant with content), and page/404 rendering.
|
The shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by `Node.order`; nav links to content-less labels point at their first child via `first_leaf`, the first published descendant with content), and page/404 rendering.
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
|
|||||||
|
|
||||||
- Content is written in **Markdown** with powerful extensions (tables, footnotes, code highlighting, etc.).
|
- Content is written in **Markdown** with powerful extensions (tables, footnotes, code highlighting, etc.).
|
||||||
- **Embedded HTML is passed through unfiltered**, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
|
- **Embedded HTML is passed through unfiltered**, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
|
||||||
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box (leaning into the empty right gutter on wide single-column pages) and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
|
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box and `{.margin}` / `::: margin` marks any block a margin note — on all but phone widths they float in the side zone at the article's left (the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
|
||||||
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `{.right}` — `{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
|
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `{.right}` — `{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.margin}` makes it a margin note, floating in the side zone left of the text on all but phone widths, `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
|
||||||
|
|
||||||
## Page structure and navigation
|
## Page structure and navigation
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
|
|||||||
|
|
||||||
## Reading experience
|
## Reading experience
|
||||||
|
|
||||||
- The article column is sized by the **viewport, never by content**: a symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it; wide screens get columns inside long articles without changing the article's width. Columns are decided client-side (pagerite.js): the body splits into segments at h1/h2 headings and `.wide` elements (full-width separators, never inside columns), and a segment gets columns only when it holds enough text — code blocks are excluded from that measure, and a `::: nocols` container opts its whole section out.
|
- The article column is sized by the **viewport, never by content**: a symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it. Long articles (flagged `.multicol` by the backend render) lift the cap and become a bounded **composition**, centered in the available space with the surplus left vacant: a fluid text lane (up to 42rem) plus a 16rem **side zone at the article's left** — the region the nav sidebar overlays — which hosts margin boxes (`.margin`, `::: aside`, margin figures) at all but phone widths, without the text ever moving. On pages with a sidebar below ~110rem (where the sidebar gets its own 12rem track) the track is the left lane instead: no in-article zone, the text lane runs fluid up to 86rem, and the boxes fall into the track, sliding under the translucent sticky nav. Once two lanes fit beside the zone (≥96rem available in `main`), the text flows in two fluid lanes (36rem minimum, capped at 102rem total — technical content wants the wider lanes, and wider windows just add vacant space). The stages step by the space actually available in `main` (container queries + `cqw` units, so the docked editor's inset is automatic). `.wide` figures on multicol pages bleed to the viewport edges measured from `main` (`cqw`), sliding under the sidebar. The backend splits the body into `.colseg` segments at h1/h2 headings, `.wide` elements and margin blocks (full-width separators or margin boxes, never inside columns), tagging segments that hold enough text with `.cols` — code blocks are excluded from that measure, and a `::: nocols` container opts its whole section out. On wide single-column pages (≥104rem), margin boxes lean into the vacant left gutter as well.
|
||||||
- A gentle **scroll-reveal** of headings, figures and block-level elements (IntersectionObserver). It is layout-level: articles need no support for it, and `prefers-reduced-motion` disables all motion.
|
- A gentle **scroll-reveal** of headings, figures and block-level elements (IntersectionObserver). It is layout-level: articles need no support for it, and `prefers-reduced-motion` disables all motion.
|
||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|||||||
@@ -240,9 +240,13 @@ function runScripts(root) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewIntoArticle(html, hasH1) {
|
function previewIntoArticle(html, hasH1, multicol) {
|
||||||
const article = document.querySelector('#main article')
|
const article = document.querySelector('#main article')
|
||||||
if (!article) return
|
if (!article) return
|
||||||
|
// The server render owns the column layout: .multicol on the article,
|
||||||
|
// the segmented .colseg/.cols structure inside .body. Both arrive with
|
||||||
|
// the preview and must stay in sync as edits cross the thresholds.
|
||||||
|
article.classList.toggle('multicol', multicol)
|
||||||
const h1 = article.querySelector('h1')
|
const h1 = article.querySelector('h1')
|
||||||
const body = article.querySelector('.body')
|
const body = article.querySelector('.body')
|
||||||
// The edit pen may be tucked inside an h1 (title or markdown-owned);
|
// The edit pen may be tucked inside an h1 (title or markdown-owned);
|
||||||
@@ -270,7 +274,7 @@ function onMessage(ev) {
|
|||||||
requestRender()
|
requestRender()
|
||||||
dirty.value = false // just loaded from the server, nothing unsaved
|
dirty.value = false // just loaded from the server, nothing unsaved
|
||||||
} else if (msg.type === 'html' && msg.path === path.value) {
|
} else if (msg.type === 'html' && msg.path === path.value) {
|
||||||
previewIntoArticle(msg.html, msg.has_h1)
|
previewIntoArticle(msg.html, msg.has_h1, msg.multicol)
|
||||||
} else if (msg.type === 'saved') {
|
} else if (msg.type === 'saved') {
|
||||||
saveError.value = ''
|
saveError.value = ''
|
||||||
pendingSave = null
|
pendingSave = null
|
||||||
|
|||||||
@@ -275,14 +275,25 @@ body {
|
|||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 78rem) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 78rem) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Long articles (.multicol is added by pagerite.js based on content length)
|
/* Long articles (.multicol comes from the backend render, based on
|
||||||
lift the 78rem cap and scrap the right gutter: a 1fr left gutter (which
|
content length — code excluded) lift the 78rem cap: main takes the full
|
||||||
holds the overlaying sidebar) and the article taking all the rest, out
|
width and the article composes itself inside it — fluid, bounded text
|
||||||
to the right viewport edge. The column count follows the width (see the
|
lanes, centered, with the surplus left vacant (see the article layout
|
||||||
`columns: 30rem` rule below). The .wide breakout is re-anchored to the
|
rules below). The left track — main always sits in column 2 — collapses
|
||||||
left gutter below (the article is no longer viewport-centered). */
|
to zero when the page has no sidebar; the sidebar then simply overlays
|
||||||
|
the vacant zone, as it does on single-column pages. */
|
||||||
body:has(.multicol) #content {
|
body:has(.multicol) #content {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr);
|
grid-template-columns: 0 minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only when the vacant zone cannot hold the sidebar does it get its own
|
||||||
|
12rem track — the same compromise single-column pages make below
|
||||||
|
102rem. Above that, a sidebar's presence changes nothing about the
|
||||||
|
article. */
|
||||||
|
@media (max-width: 110rem) {
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) #content {
|
||||||
|
grid-template-columns: 12rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.editing #content {
|
body.editing #content {
|
||||||
@@ -420,6 +431,11 @@ main {
|
|||||||
/* No top padding: a leading wide image sits flush under the banner, and
|
/* No top padding: a leading wide image sits flush under the banner, and
|
||||||
text-first pages get their spacing from the h1's top margin instead. */
|
text-first pages get their spacing from the h1's top margin instead. */
|
||||||
padding: 0 1.25rem 3rem;
|
padding: 0 1.25rem 3rem;
|
||||||
|
/* The layout container for the article composition: the multicol stage
|
||||||
|
(lane count), the margin fall and the .wide bleed respond to the
|
||||||
|
actual available width here — editor inset included — via container
|
||||||
|
queries and cqw units. */
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
article h1,
|
article h1,
|
||||||
@@ -561,19 +577,108 @@ article dd {
|
|||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Multi-column reading, but only for long articles (pagerite.js adds
|
/* The long-article composition (.multicol): a fluid but bounded text
|
||||||
.multicol based on content length — code blocks excluded — and splits
|
lane with a 16rem side zone at the article's left, centered in main —
|
||||||
the body into .colseg segments separated by full-width h2s and .wide
|
surplus width becomes vacant space, never endless text. (The backend
|
||||||
elements; only segments with enough text get .cols, and a ::: nocols
|
render splits the body into .colseg segments separated by full-width
|
||||||
container opts its section out). No fixed breakpoint: `columns: 30rem` lets CSS fit as
|
h2s and .wide elements, tags text-heavy segments .cols — a ::: nocols
|
||||||
many columns of at least 30rem as the article's current width allows —
|
container opts its section out — and flags the article .multicol; CSS
|
||||||
since .multicol also uncaps the article width (see #content above), a
|
owns the geometry.) Technical content wants a wide lane: up to 42rem
|
||||||
wider window simply yields more columns. */
|
single, or two fluid lanes (36rem minimum, never more than two) once
|
||||||
|
they fit beside the zone, capped at 102rem total. The zone — the
|
||||||
|
region the nav sidebar overlays — is a margin indent on the lane
|
||||||
|
content; margin boxes float into it, and the text never moves. */
|
||||||
|
article.multicol {
|
||||||
|
margin-inline: auto;
|
||||||
|
max-width: 58rem; /* 42rem lane + 16rem zone */
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (min-width: 45rem) {
|
||||||
|
/* The side zone (not on phones): lane content indents 16rem; margin
|
||||||
|
boxes ({.margin} / ::: margin blocks, ::: aside, {.margin} figures)
|
||||||
|
float at the article's left edge — the same region the nav sidebar
|
||||||
|
overlays. Scoped to direct .body children (the backend render keeps
|
||||||
|
margin blocks out of the column segments); nested ones keep the
|
||||||
|
in-column float fallback. */
|
||||||
|
.multicol .body>.colseg,
|
||||||
|
.multicol .body>h1,
|
||||||
|
.multicol .body>h2,
|
||||||
|
article.multicol>h1 {
|
||||||
|
margin-left: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multicol .body>.margin,
|
||||||
|
.multicol .body>.aside,
|
||||||
|
.multicol .body>figure:has(.margin) {
|
||||||
|
float: left;
|
||||||
|
clear: left;
|
||||||
|
width: 14rem;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0.3rem 2rem 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wide separators start below any margin box — their bleed must not
|
||||||
|
wrap around it. */
|
||||||
|
.multicol .body>figure:has(.wide),
|
||||||
|
.multicol .body>div.wide,
|
||||||
|
.multicol .body>pre.wide {
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (min-width: 96rem) {
|
||||||
|
article.multicol {
|
||||||
|
max-width: 102rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two fluid lanes (36rem minimum) beside the zone, up to the 102rem
|
||||||
|
cap — wider windows just add vacant space. */
|
||||||
.multicol .colseg.cols {
|
.multicol .colseg.cols {
|
||||||
columns: 30rem;
|
columns: 36rem 2;
|
||||||
column-gap: 3.5rem;
|
column-gap: 3.5rem;
|
||||||
column-rule: 1px solid var(--line);
|
column-rule: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* With a sidebar, below 110rem the sidebar gets its own 12rem track (see
|
||||||
|
#content). The track IS the left lane then: no in-article zone, the
|
||||||
|
text lane runs fluid (up to 86rem), and margin boxes fall all the way
|
||||||
|
left into the track — sliding under the translucent sticky nav, which
|
||||||
|
only ever occupies its top. (The track never exists once the container
|
||||||
|
reaches 96rem, so the two-lane rules above never meet it. Not below
|
||||||
|
48rem: there the sidebar becomes a link strip above the article and
|
||||||
|
there is no track to fall into.) */
|
||||||
|
@media (min-width: 48rem) and (max-width: 110rem) {
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) article.multicol {
|
||||||
|
max-width: 86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>.colseg,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>h1,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>h2,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>h1 {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>.margin,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>.aside,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>figure:has(.margin) {
|
||||||
|
float: left;
|
||||||
|
clear: left;
|
||||||
|
width: 12rem;
|
||||||
|
max-width: none;
|
||||||
|
/* From the text's left edge to the page's left edge: half the
|
||||||
|
centering difference plus the track and main's padding. */
|
||||||
|
margin: 0.3rem 0 1rem calc(50% - 50cqw - 13.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A shrink-wrapped figure (explicit image width) centers in the plain
|
||||||
|
layout; inside a column the centering looks adrift — left-align.
|
||||||
|
Floated figures keep their own margins (the text gap). */
|
||||||
|
.multicol .colseg.cols figure:has(img[width]):not(:has(.left), :has(.right), :has(.margin)) {
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.multicol .colseg {
|
.multicol .colseg {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -594,6 +699,8 @@ article dd {
|
|||||||
pre,
|
pre,
|
||||||
blockquote,
|
blockquote,
|
||||||
table,
|
table,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
dl,
|
dl,
|
||||||
.admonition,
|
.admonition,
|
||||||
.markdown-alert {
|
.markdown-alert {
|
||||||
@@ -723,19 +830,20 @@ blockquote p + p {
|
|||||||
--admonition-color: var(--accent3);
|
--admonition-color: var(--accent3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Asides (::: aside): a floated side box in the floated-figure idiom;
|
/* Side boxes: ::: aside is a muted floated box (consecutive asides stack
|
||||||
consecutive asides stack (clear: right). On wide single-column pages it
|
via clear: left); {.margin} / ::: margin is a plainer margin note, and
|
||||||
leans into the empty right gutter (below 104rem the gutter cannot hold
|
figures take {.margin} like {.left}. On multicol pages they float in
|
||||||
the box; multicol pages have no right gutter at all, and while editing
|
the composition's left side zone — or in the sidebar's track when the
|
||||||
the docked panel reshapes the gutters — in all these it stays a plain
|
layout reserves one (see the article section); on wide single-column
|
||||||
float). Headings already clear floats, so asides never bleed into the
|
pages they lean into the left gutter (with the figure rules below);
|
||||||
next section. */
|
otherwise they stay in-column left floats. Headings already clear
|
||||||
|
floats, so boxes never bleed into the next section. */
|
||||||
.aside {
|
.aside {
|
||||||
float: right;
|
float: left;
|
||||||
clear: right;
|
clear: left;
|
||||||
width: 30%;
|
width: 30%;
|
||||||
max-width: 20rem;
|
max-width: 20rem;
|
||||||
margin: 0.3rem 0 1rem 1.2rem;
|
margin: 0.3rem 1.2rem 1rem 0;
|
||||||
padding: 0.6rem 0.9rem;
|
padding: 0.6rem 0.9rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -743,15 +851,19 @@ blockquote p + p {
|
|||||||
border-radius: 0.3rem;
|
border-radius: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside> :last-child {
|
.aside> :last-child,
|
||||||
|
.margin> :last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 104rem) {
|
.margin {
|
||||||
body:not(.editing):not(:has(.multicol)) .aside {
|
float: left;
|
||||||
width: 12rem;
|
clear: left;
|
||||||
margin-right: -13rem;
|
width: 30%;
|
||||||
}
|
max-width: 20rem;
|
||||||
|
margin: 0.3rem 1.2rem 1rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
@@ -915,6 +1027,31 @@ figure:has(img[width]) {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* {.margin} figures float left like {.left} ones — until they fall into
|
||||||
|
the side zone (see the composition rules up in the article section). */
|
||||||
|
figure:has(.margin) {
|
||||||
|
float: left;
|
||||||
|
width: 30%;
|
||||||
|
max-width: 50%;
|
||||||
|
margin: 0.3rem 1em 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wide single-column pages: margin boxes lean into the vacant left
|
||||||
|
gutter instead (below 104rem the gutter cannot hold the box, and while
|
||||||
|
editing the docked panel reshapes the gutters — in both they stay
|
||||||
|
plain floats). */
|
||||||
|
@media (min-width: 104rem) {
|
||||||
|
body:not(.editing):not(:has(.multicol)) .body>.margin,
|
||||||
|
body:not(.editing):not(:has(.multicol)) .body>.aside,
|
||||||
|
body:not(.editing):not(:has(.multicol)) .body>figure:has(.margin) {
|
||||||
|
float: left;
|
||||||
|
clear: left;
|
||||||
|
width: 12rem;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0.3rem 0 1rem -13.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* .wide is full bleed: edge to edge of the viewport (or of the space right
|
/* .wide is full bleed: edge to edge of the viewport (or of the space right
|
||||||
of the docked editor), while staying in flow so it keeps its vertical
|
of the docked editor), while staying in flow so it keeps its vertical
|
||||||
space. The article column is centered in the available space, so negative
|
space. The article column is centered in the available space, so negative
|
||||||
@@ -943,42 +1080,53 @@ pre.wide {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .wide on multicol pages: the article is not viewport-centered (no right
|
|
||||||
gutter), so the bleed anchors at the left gutter — the 1fr share of the
|
|
||||||
1fr + 4fr grid, i.e. 20vw — plus main's padding, and spans on to the
|
|
||||||
right viewport edge. */
|
|
||||||
body:has(.multicol) figure:has(.wide),
|
|
||||||
body:has(.multicol) pre.wide {
|
|
||||||
margin-inline: calc(-20vw - 1.25rem) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Editing: shrink the bleed to the space right of the docked editor. The
|
/* Editing: shrink the bleed to the space right of the docked editor. The
|
||||||
window keeps its overlay scrollbars while editing, so — unlike a classic
|
window keeps its overlay scrollbars while editing, so — unlike a classic
|
||||||
scrollbar — they take no layout space and the vw math stays exact. */
|
scrollbar — they take no layout space and the vw math stays exact.
|
||||||
|
Multicol pages measure the bleed from main instead (cqw rules below),
|
||||||
|
which insets for the editor automatically. */
|
||||||
body.editing figure:has(.wide),
|
body.editing figure:has(.wide),
|
||||||
body.editing pre.wide {
|
body.editing pre.wide {
|
||||||
width: calc(100vw - var(--editor-w));
|
width: calc(100vw - var(--editor-w));
|
||||||
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
|
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Editing + multicol: the left gutter is 1/5 of the space right of the
|
/* Narrow single-column pages with a sidebar: below 102rem the symmetric
|
||||||
editor, and the bleed also crosses main's 1.25rem left padding. */
|
gutters can no longer both hold the 12rem sidebar, so #content reserves
|
||||||
body.editing:has(.multicol) figure:has(.wide),
|
it with a fixed left track (see the matching media query below) and the
|
||||||
body.editing:has(.multicol) pre.wide {
|
article always starts at 12rem (+ main's 1.25rem padding) — the bleed
|
||||||
margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0;
|
margin is a plain constant. Scoped by :has(#sidebar) since the sidebar
|
||||||
|
element is omitted entirely on pages without sub-navigation, and
|
||||||
|
excluded while editing, where the editing rules above apply instead.
|
||||||
|
(Multicol pages use the same fixed track below 110rem — their rules
|
||||||
|
are below.) */
|
||||||
|
@media (max-width: 102rem) {
|
||||||
|
body:has(#sidebar):not(.editing):not(:has(.multicol)) figure:has(.wide),
|
||||||
|
body:has(#sidebar):not(.editing):not(:has(.multicol)) pre.wide {
|
||||||
|
margin-inline: -13.25rem 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
|
/* .wide on multicol pages: the same edge-to-edge bleed, but measured from
|
||||||
longer both hold the 12rem sidebar, so #content reserves it with a fixed
|
main (the container) instead of the viewport — cqw includes the editor
|
||||||
left track (see the matching media query below) and the article always
|
inset automatically, so no editing override is needed. The 2.5rem
|
||||||
starts at 12rem (+ main's 1.25rem padding) — the bleed margin is a plain
|
covers main's side padding. */
|
||||||
constant. Scoped by :has(#sidebar) since the sidebar element is omitted
|
body:has(.multicol) figure:has(.wide),
|
||||||
entirely on pages without sub-navigation, and excluded while editing,
|
body:has(.multicol) div.wide,
|
||||||
where the editing rules above apply instead. */
|
body:has(.multicol) pre.wide {
|
||||||
@media (max-width: 102rem) {
|
width: calc(100cqw + 2.5rem);
|
||||||
body:has(#sidebar):not(.editing) figure:has(.wide),
|
margin-inline: calc(50% - 50cqw - 1.25rem);
|
||||||
body:has(#sidebar):not(.editing) pre.wide {
|
}
|
||||||
margin-inline: -13.25rem 0;
|
|
||||||
|
/* Multicol with a sidebar track (≤110rem, see #content): main starts
|
||||||
|
12rem in, so the bleed extends left past the track to the true viewport
|
||||||
|
edge — sliding under the translucent sidebar. */
|
||||||
|
@media (max-width: 110rem) {
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) figure:has(.wide),
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) div.wide,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) pre.wide {
|
||||||
|
width: calc(100cqw + 14.5rem);
|
||||||
|
margin-inline: calc(50% - 50cqw - 13.25rem) 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,30 +1163,23 @@ article h2 {
|
|||||||
|
|
||||||
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
|
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
|
||||||
longer both hold the 12rem sidebar, so reserve its space with a fixed
|
longer both hold the 12rem sidebar, so reserve its space with a fixed
|
||||||
left track instead of letting it overlap the article. The article then
|
left track instead of letting it overlap the article (multicol pages use
|
||||||
always starts at 12rem (+ main's 1.25rem padding), which the matching
|
this same track at every width — see the #content rules above). The
|
||||||
.wide breakout rule in the images section relies on. Scoped by
|
article then always starts at 12rem (+ main's 1.25rem padding), which
|
||||||
:has(#sidebar) since the sidebar element is omitted entirely on pages
|
the matching .wide breakout rule in the images section relies on.
|
||||||
without sub-navigation. */
|
Scoped by :has(#sidebar) since the sidebar element is omitted entirely
|
||||||
|
on pages without sub-navigation. */
|
||||||
@media (max-width: 102rem) {
|
@media (max-width: 102rem) {
|
||||||
body:has(#sidebar):not(.editing) #content {
|
body:has(#sidebar):not(.editing) #content {
|
||||||
grid-template-columns: 12rem minmax(0, 78rem) minmax(0, 1fr);
|
grid-template-columns: 12rem minmax(0, 78rem) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Long articles stay fluid here too: same 12rem reservation for the
|
|
||||||
sidebar, then the article takes everything to the right viewport
|
|
||||||
edge. The article's left edge stays at 12rem either way, so the
|
|
||||||
constant .wide breakout margin remains correct. */
|
|
||||||
body:has(#sidebar):has(.multicol):not(.editing) #content {
|
|
||||||
grid-template-columns: 12rem minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phones and other narrow viewports: single-column layout with the
|
/* Phones and other narrow viewports: single-column layout with the
|
||||||
sidebar lifted above the article as a wrapping link strip, and no
|
sidebar lifted above the article as a wrapping link strip, and no
|
||||||
floated figures — .left/.right fall back to plain centered figures
|
floats at all — .left/.right/.margin figures fall back to plain
|
||||||
(explicit img widths still shrink-wrap), while .wide keeps its full
|
centered figures (explicit img widths still shrink-wrap), margin boxes
|
||||||
viewport bleed. */
|
go full width, while .wide keeps its full viewport bleed. */
|
||||||
@media (max-width: 48rem) {
|
@media (max-width: 48rem) {
|
||||||
|
|
||||||
/* Nav type shrinks fluidly as space runs out. The nav font-size is
|
/* Nav type shrinks fluidly as space runs out. The nav font-size is
|
||||||
@@ -1103,24 +1244,35 @@ article h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
figure:has(.right),
|
figure:has(.right),
|
||||||
figure:has(.left) {
|
figure:has(.left),
|
||||||
|
figure:has(.margin) {
|
||||||
float: none;
|
float: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0 auto 1.5rem;
|
margin: 0 auto 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Margin boxes go full width too — no room for side floats on a
|
||||||
|
phone. */
|
||||||
|
.aside,
|
||||||
|
.margin {
|
||||||
|
float: none;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* An explicit img width still shrink-wraps the figure (redeclared: this
|
/* An explicit img width still shrink-wraps the figure (redeclared: this
|
||||||
block comes after the desktop rule at equal specificity). */
|
block comes after the desktop rule at equal specificity). */
|
||||||
figure:has(img[width]) {
|
figure:has(img[width]) {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The 102rem sidebar/multicol .wide margins assume a left sidebar
|
/* The single-column sidebar .wide margins assume a left sidebar column;
|
||||||
column; with the sidebar on top the article is viewport-wide and the
|
with the sidebar on top the article is viewport-wide and the plain
|
||||||
plain centered bleed applies again. */
|
centered bleed applies again. (Multicol pages need no override: their
|
||||||
body:has(#sidebar):not(.editing) figure:has(.wide),
|
cqw bleed is exact at any width.) */
|
||||||
body:has(.multicol) figure:has(.wide) {
|
body:has(#sidebar):not(.editing):not(:has(.multicol)) figure:has(.wide) {
|
||||||
margin-inline: calc(50% - 50vw);
|
margin-inline: calc(50% - 50vw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,66 +286,8 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
// created page has no pen for commitPending's handover click.
|
// created page has no pen for commitPending's handover click.
|
||||||
renderAuthUi();
|
renderAuthUi();
|
||||||
placeEditPen();
|
placeEditPen();
|
||||||
// Preview swaps also wipe the .colseg wrappers (the server render has
|
|
||||||
// none), which would drop the multi-column layout until a full reload;
|
|
||||||
// re-split so columns survive both live editing and closing the editor.
|
|
||||||
const main = document.getElementById("main");
|
|
||||||
if (main) applyMulticol(main);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Multi-column layout only when there is enough text to justify it.
|
|
||||||
// Split the body into columned segments: h1s, h2s and wide elements are
|
|
||||||
// full-width separators and never go inside columns.
|
|
||||||
function applyMulticol(main) {
|
|
||||||
const article = main.querySelector("article");
|
|
||||||
if (!article) return;
|
|
||||||
const body = article.querySelector(".body");
|
|
||||||
// Code blocks don't read as flowing text and are often generated
|
|
||||||
// filler; exclude them when measuring whether the text justifies
|
|
||||||
// columns.
|
|
||||||
const textLen = (el) => {
|
|
||||||
let n = el.textContent.trim().length;
|
|
||||||
for (const pre of el.querySelectorAll("pre")) n -= pre.textContent.length;
|
|
||||||
return n;
|
|
||||||
};
|
|
||||||
article.classList.toggle(
|
|
||||||
"multicol",
|
|
||||||
!!body && textLen(body) > 1800,
|
|
||||||
);
|
|
||||||
if (body && article.classList.contains("multicol")
|
|
||||||
&& !body.querySelector(".colseg")) {
|
|
||||||
// h1s, h2s and wide elements (a {.wide} block or anything holding
|
|
||||||
// one, e.g. a figure with a wide image) are full-width separators
|
|
||||||
const isSeparator = (el) =>
|
|
||||||
el.tagName === "H1" || el.tagName === "H2"
|
|
||||||
|| el.classList.contains("wide")
|
|
||||||
|| el.querySelector(".wide") !== null;
|
|
||||||
let seg = null;
|
|
||||||
for (const el of [...body.children]) {
|
|
||||||
if (isSeparator(el)) {
|
|
||||||
seg = null;
|
|
||||||
body.append(el);
|
|
||||||
} else {
|
|
||||||
if (!seg) {
|
|
||||||
seg = document.createElement("div");
|
|
||||||
seg.className = "colseg";
|
|
||||||
body.append(seg);
|
|
||||||
}
|
|
||||||
seg.append(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Columns are per section: only segments with enough text get them,
|
|
||||||
// so a short ingress or a brief section stays single-column. A
|
|
||||||
// .nocols container (::: nocols) opts its whole section out.
|
|
||||||
for (const s of body.querySelectorAll(".colseg")) {
|
|
||||||
s.classList.toggle(
|
|
||||||
"cols",
|
|
||||||
s.querySelector(".nocols") === null && textLen(s) > 600,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyEffects() {
|
function applyEffects() {
|
||||||
(window.requestIdleCallback || setTimeout)(preload);
|
(window.requestIdleCallback || setTimeout)(preload);
|
||||||
const main = document.getElementById("main");
|
const main = document.getElementById("main");
|
||||||
@@ -355,7 +297,6 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
renderAuthUi();
|
renderAuthUi();
|
||||||
placeEditPen();
|
placeEditPen();
|
||||||
fitNav();
|
fitNav();
|
||||||
applyMulticol(main);
|
|
||||||
if (reduceMotion.matches) return;
|
if (reduceMotion.matches) return;
|
||||||
for (const el of main.querySelectorAll(
|
for (const el of main.querySelectorAll(
|
||||||
"h2, h3, figure, img, pre, blockquote, table, dl, .task-list-item",
|
"h2, h3, figure, img, pre, blockquote, table, dl, .task-list-item",
|
||||||
|
|||||||
+10
-5
@@ -1015,15 +1015,20 @@ 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
|
||||||
await ws.send_json({
|
rendered = render(
|
||||||
"type": "html",
|
|
||||||
"path": path,
|
|
||||||
"html": render(
|
|
||||||
markdown,
|
markdown,
|
||||||
path,
|
path,
|
||||||
node.created if node else None,
|
node.created if node else None,
|
||||||
node.modified if node else None,
|
node.modified if node else None,
|
||||||
),
|
)
|
||||||
|
await ws.send_json({
|
||||||
|
"type": "html",
|
||||||
|
"path": path,
|
||||||
|
"html": rendered.html,
|
||||||
|
# Column-layout flags: the preview toggles the
|
||||||
|
# article's .multicol class and swaps in the
|
||||||
|
# segmented (.colseg/.cols) body html.
|
||||||
|
"multicol": rendered.multicol,
|
||||||
"has_h1": has_h1(markdown),
|
"has_h1": has_h1(markdown),
|
||||||
})
|
})
|
||||||
case "save":
|
case "save":
|
||||||
|
|||||||
+145
-13
@@ -11,8 +11,10 @@ same callout styling). ``::: name`` opens a generic container rendered
|
|||||||
as ``<div class="name">`` and closed by a matching ``:::`` (nest by
|
as ``<div class="name">`` and closed by a matching ``:::`` (nest by
|
||||||
giving the outer container more colons, e.g. `::::`); the name may be
|
giving the outer container more colons, e.g. `::::`); the name may be
|
||||||
followed by brace attributes (``::: aside {.right}``). ``::: aside``
|
followed by brace attributes (``::: aside {.right}``). ``::: aside``
|
||||||
floats as a side box beside the text and ``::: nocols`` opts its
|
floats as a muted side box, floating in the side zone at the article's
|
||||||
section out of the column layout. A brace-attribute
|
left on all but phone widths — the same margin float ``{.margin}`` (or
|
||||||
|
``::: margin``) gives any block — and ``::: nocols`` opts its section out
|
||||||
|
of the column layout. A brace-attribute
|
||||||
line as a block's last line (no blank line between) applies to the whole
|
line as a block's last line (no blank line between) applies to the whole
|
||||||
block, e.g. a paragraph ending with ``{.wide}`` breaks out of the column
|
block, e.g. a paragraph ending with ``{.wide}`` breaks out of the column
|
||||||
layout as a full-width element; written after a block (code fence,
|
layout as a full-width element; written after a block (code fence,
|
||||||
@@ -21,6 +23,17 @@ the ``https://`` scheme hidden in the link text (``http://`` and other
|
|||||||
schemes stay visible; manually labelled links are untouched), and
|
schemes stay visible; manually labelled links are untouched), and
|
||||||
``H~2~O`` / ``x^2^`` give sub/superscripts.
|
``H~2~O`` / ``x^2^`` give sub/superscripts.
|
||||||
|
|
||||||
|
render() also builds the layout structure: the top-level blocks are
|
||||||
|
segmented for the column layout — h1/h2 headings, ``.wide`` blocks and
|
||||||
|
margin-breakout blocks (``.margin``, ``::: aside``) stand on their own,
|
||||||
|
the runs between them are wrapped in ``<div class="colseg">`` (tagged
|
||||||
|
``.cols`` when the segment holds enough text, unless a ``::: nocols``
|
||||||
|
container opts it out). The result carries ``multicol`` when the whole
|
||||||
|
body justifies columns (views.py puts the class on the article); how
|
||||||
|
many columns (never more than two), whether the margin breakout applies
|
||||||
|
and every other viewport adaptation is then pagerite.css's call. The
|
||||||
|
thresholds measure visible text, code blocks excluded.
|
||||||
|
|
||||||
markdown-it's typographer is enabled, so body text gets SmartyPants-style
|
markdown-it's typographer is enabled, so body text gets SmartyPants-style
|
||||||
replacements: straight quotes become curly, ``--`` / ``---`` become en / em
|
replacements: straight quotes become curly, ``--`` / ``---`` become en / em
|
||||||
dashes, ``...`` becomes an ellipsis, ``(c)`` becomes ©, and so on. Single
|
dashes, ``...`` becomes an ellipsis, ``(c)`` becomes ©, and so on. Single
|
||||||
@@ -38,6 +51,7 @@ classes, e.g. `{.right}`.
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
from markdown_it import MarkdownIt
|
from markdown_it import MarkdownIt
|
||||||
from markdown_it.common.utils import escapeHtml
|
from markdown_it.common.utils import escapeHtml
|
||||||
@@ -219,16 +233,23 @@ def _container_validate(params: str, _markup: str) -> bool:
|
|||||||
return pos == len(rest) - 1
|
return pos == len(rest) - 1
|
||||||
|
|
||||||
|
|
||||||
def _container_render(self, tokens, idx, options, env):
|
def _container_attrs(state) -> None:
|
||||||
"""Render `::: name {attrs}` containers as `<div class="name">`."""
|
"""Apply `::: name {attrs}` classes to container tokens at parse time.
|
||||||
token = tokens[idx]
|
|
||||||
if token.nesting == 1:
|
The container plugin's default render is a plain renderToken, so the
|
||||||
|
name and brace attributes must live on the token itself — and being a
|
||||||
|
core rule (rather than a render rule) lets the segmentation in
|
||||||
|
render() see the classes (::: aside's margin breakout, the ::: nocols
|
||||||
|
opt-out, {.wide} containers).
|
||||||
|
"""
|
||||||
|
for token in state.tokens:
|
||||||
|
if token.type != "container_block_open":
|
||||||
|
continue
|
||||||
name, _, rest = token.info.strip().partition(" ")
|
name, _, rest = token.info.strip().partition(" ")
|
||||||
token.attrJoin("class", name)
|
token.attrJoin("class", name)
|
||||||
if rest.strip():
|
if rest.strip():
|
||||||
_, attrs = parse_attrs(rest.strip())
|
_, attrs = parse_attrs(rest.strip())
|
||||||
_apply_attrs(token, attrs)
|
_apply_attrs(token, attrs)
|
||||||
return self.renderToken(tokens, idx, options, env)
|
|
||||||
|
|
||||||
|
|
||||||
def _block_attrs(state) -> None:
|
def _block_attrs(state) -> None:
|
||||||
@@ -301,8 +322,7 @@ md = (
|
|||||||
)
|
)
|
||||||
.use(attrs_plugin)
|
.use(attrs_plugin)
|
||||||
.use(admon_plugin)
|
.use(admon_plugin)
|
||||||
.use(container_plugin, "block", validate=_container_validate,
|
.use(container_plugin, "block", validate=_container_validate)
|
||||||
render=_container_render)
|
|
||||||
.use(footnote_plugin)
|
.use(footnote_plugin)
|
||||||
.use(deflist_plugin)
|
.use(deflist_plugin)
|
||||||
.use(tasklists_plugin, enabled=True)
|
.use(tasklists_plugin, enabled=True)
|
||||||
@@ -316,28 +336,140 @@ md.add_render_rule("fence", _fence_rule)
|
|||||||
md.options["alerts"] = True
|
md.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.
|
||||||
md.core.ruler.before("replacements", "block_attrs", _block_attrs)
|
md.core.ruler.before("replacements", "block_attrs", _block_attrs)
|
||||||
|
md.core.ruler.push("container_attrs", _container_attrs)
|
||||||
md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
|
md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
|
||||||
md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
|
md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
|
||||||
md.core.ruler.push("shorten_autolinks", _shorten_autolinks)
|
md.core.ruler.push("shorten_autolinks", _shorten_autolinks)
|
||||||
|
|
||||||
|
|
||||||
|
# Text-length thresholds (visible characters, code blocks excluded) for the
|
||||||
|
# column layout: the article goes .multicol past MULTICOL_TEXT, and a column
|
||||||
|
# segment gets .cols past COLS_TEXT.
|
||||||
|
MULTICOL_TEXT = 1800
|
||||||
|
COLS_TEXT = 600
|
||||||
|
|
||||||
|
_PRE_BLOCK_RE = re.compile(r"<pre\b.*?</pre>", re.S)
|
||||||
|
_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
|
# Classes that take their block out of the column flow: .wide is a
|
||||||
|
# full-width separator, .margin/.aside float in the side zone at the
|
||||||
|
# article's left (they must be direct .body children for that — the zone
|
||||||
|
# rules key off it — never inside a column).
|
||||||
|
_WIDE = "wide"
|
||||||
|
_BREAKOUT = ("margin", "aside")
|
||||||
|
|
||||||
|
|
||||||
|
class Rendered(NamedTuple):
|
||||||
|
"""render() result: the segmented body HTML, and whether the article
|
||||||
|
should carry .multicol (enough visible text to justify columns)."""
|
||||||
|
|
||||||
|
html: str
|
||||||
|
multicol: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _classes(token) -> set[str]:
|
||||||
|
return set((token.attrGet("class") or "").split())
|
||||||
|
|
||||||
|
|
||||||
|
def _text_len(html: str) -> int:
|
||||||
|
"""Visible-text length of rendered HTML, code blocks excluded."""
|
||||||
|
return len(_TAG_RE.sub("", _PRE_BLOCK_RE.sub("", html)).strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _top_level_blocks(tokens: list) -> list[list]:
|
||||||
|
"""Split the token stream into its top-level blocks.
|
||||||
|
|
||||||
|
A new block starts at each level-0 opening/self-contained token;
|
||||||
|
closing and nested tokens (inline children, sub-containers) belong to
|
||||||
|
the current block, so every slice is balanced and renders on its own.
|
||||||
|
"""
|
||||||
|
blocks = []
|
||||||
|
for token in tokens:
|
||||||
|
if token.level == 0 and token.nesting >= 0:
|
||||||
|
blocks.append([token])
|
||||||
|
elif blocks:
|
||||||
|
blocks[-1].append(token)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _is_boundary(block: list) -> bool:
|
||||||
|
"""True for blocks that never go inside a column segment (see the
|
||||||
|
_WIDE/_BREAKOUT comment above): h1/h2 headings, anything carrying
|
||||||
|
.wide, and blocks whose own element carries .margin/.aside — for a
|
||||||
|
lone-image paragraph (which renders as a <figure>) the image's classes
|
||||||
|
count as the block's own."""
|
||||||
|
first = block[0]
|
||||||
|
if first.type == "heading_open" and first.tag in ("h1", "h2"):
|
||||||
|
return True
|
||||||
|
own = _classes(first)
|
||||||
|
for token in block:
|
||||||
|
if _WIDE in _classes(token):
|
||||||
|
return True
|
||||||
|
if token.type == "inline":
|
||||||
|
children = token.children or []
|
||||||
|
if any(_WIDE in _classes(c) for c in children):
|
||||||
|
return True
|
||||||
|
if len(children) == 1 and children[0].type == "image":
|
||||||
|
own |= _classes(children[0])
|
||||||
|
return bool(own & set(_BREAKOUT))
|
||||||
|
|
||||||
|
|
||||||
def render(
|
def render(
|
||||||
text: str,
|
text: str,
|
||||||
page_path: str = "",
|
page_path: str = "",
|
||||||
created: datetime | None = None,
|
created: datetime | None = None,
|
||||||
modified: datetime | None = None,
|
modified: datetime | None = None,
|
||||||
) -> str:
|
) -> Rendered:
|
||||||
"""Render Markdown text to an HTML string.
|
"""Render Markdown text to the article body's HTML and layout flags.
|
||||||
|
|
||||||
|
The top-level blocks are grouped into column segments: boundary blocks
|
||||||
|
(h1/h2 headings, .wide, margin-breakout blocks — see _is_boundary) are
|
||||||
|
rendered bare, the runs between them wrapped in <div class="colseg">.
|
||||||
|
A segment is tagged .cols when it holds enough text (COLS_TEXT) and no
|
||||||
|
::: nocols container; the article is .multicol when the whole body
|
||||||
|
exceeds MULTICOL_TEXT. pagerite.css keys all column and margin-breakout
|
||||||
|
layout off these 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``; left as-is in contexts without them,
|
||||||
e.g. the editor preview). Position is the author's choice — typically
|
e.g. the editor preview). Position is the author's choice — typically
|
||||||
right after the article's h1.
|
right after the article's h1.
|
||||||
"""
|
"""
|
||||||
html = md.render(text, {"page_path": page_path})
|
env = {"page_path": page_path}
|
||||||
|
blocks = _top_level_blocks(md.parse(text, env))
|
||||||
|
# Group consecutive non-boundary blocks into segments (is_segment,
|
||||||
|
# flat tokens); boundary blocks stand on their own between them.
|
||||||
|
groups: list[tuple[bool, list]] = []
|
||||||
|
for block in blocks:
|
||||||
|
if _is_boundary(block):
|
||||||
|
groups.append((False, block))
|
||||||
|
elif groups and groups[-1][0]:
|
||||||
|
groups[-1][1].extend(block)
|
||||||
|
else:
|
||||||
|
groups.append((True, list(block)))
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
total = 0
|
||||||
|
for is_segment, group in groups:
|
||||||
|
html = md.renderer.render(group, md.options, env)
|
||||||
|
if not html.strip():
|
||||||
|
continue # e.g. a consumed standalone-attrs paragraph
|
||||||
|
text_len = _text_len(html)
|
||||||
|
total += text_len
|
||||||
|
if not is_segment:
|
||||||
|
parts.append(html)
|
||||||
|
continue
|
||||||
|
nocols = any(
|
||||||
|
"nocols" in _classes(t)
|
||||||
|
for t in group
|
||||||
|
if t.type == "container_block_open"
|
||||||
|
)
|
||||||
|
cols = " cols" if text_len > COLS_TEXT and not nocols else ""
|
||||||
|
parts.append(f'<div class="colseg{cols}">{html}</div>')
|
||||||
|
html = "".join(parts)
|
||||||
if created is not None and "<p>{dates}</p>" in html:
|
if created is not None and "<p>{dates}</p>" in html:
|
||||||
html = html.replace("<p>{dates}</p>", _dateline(created, modified))
|
html = html.replace("<p>{dates}</p>", _dateline(created, modified))
|
||||||
return html
|
return Rendered(html, total > MULTICOL_TEXT)
|
||||||
|
|
||||||
|
|
||||||
def _dateline(created: datetime, modified: datetime | None) -> str:
|
def _dateline(created: datetime, modified: datetime | None) -> str:
|
||||||
|
|||||||
+7
-5
@@ -513,16 +513,18 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
|
|||||||
def page_content(menu: dict[str, Node], path: str) -> HTML:
|
def page_content(menu: dict[str, Node], path: str) -> HTML:
|
||||||
"""Render the contents of the #main element for a page."""
|
"""Render the contents of the #main element for a page."""
|
||||||
node = resolve(menu, path)[-1]
|
node = resolve(menu, path)[-1]
|
||||||
doc = E.article
|
rendered = render(node.content or "", path, node.created, node.modified)
|
||||||
|
# Long articles get .multicol: the article column cap lifts (see the
|
||||||
|
# #content grid in pagerite.css) and the body's .cols segments lay out
|
||||||
|
# in at most two columns. The .body html is already segmented by
|
||||||
|
# render() — the whole layout is driven by these classes.
|
||||||
|
doc = E.article(class_="multicol") if rendered.multicol else E.article
|
||||||
with doc:
|
with doc:
|
||||||
# An h1 in the markdown owns the article heading; the title is
|
# An h1 in the markdown owns the article heading; the title is
|
||||||
# only rendered as h1 when the markdown has none of its own.
|
# only rendered as h1 when the markdown has none of its own.
|
||||||
if not has_h1(node.content or ""):
|
if not has_h1(node.content or ""):
|
||||||
doc.h1(node.title)
|
doc.h1(node.title)
|
||||||
doc.div(
|
doc.div(HTML(rendered.html), class_="body")
|
||||||
HTML(render(node.content or "", path, node.created, node.modified)),
|
|
||||||
class_="body",
|
|
||||||
)
|
|
||||||
return HTML(str(doc))
|
return HTML(str(doc))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user