Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 — both drop into the left margin when the layout has room for it (the sticky sidebar shares that gutter and slides over them translucently, like full-bleed images), and stay in-column floats otherwise — 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}` drops it into the left margin like a margin note, `{.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 78rem cap and take everything right of the left gutter, out to the viewport edge. The backend also splits the body into `.colseg` segments at h1/h2 headings, `.wide` elements and margin-breakout 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. The CSS then fits **at most two columns** of at least 30rem per segment, so a wider window widens the pair instead of adding columns. Margin boxes (`.margin`, `::: aside`) leave the column flow for the left gutter on wide viewports and stay in-column floats below that.
|
||||||
- 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,12 +275,13 @@ 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 and scrap the right
|
||||||
holds the overlaying sidebar) and the article taking all the rest, out
|
gutter: a 1fr left gutter (which holds the overlaying sidebar and any
|
||||||
to the right viewport edge. The column count follows the width (see the
|
margin-breakout boxes) and the article taking all the rest, out to the
|
||||||
`columns: 30rem` rule below). The .wide breakout is re-anchored to the
|
right viewport edge. Columns are capped at two (see the
|
||||||
left gutter below (the article is no longer viewport-centered). */
|
`columns: 30rem 2` rule below). The .wide breakout is re-anchored to
|
||||||
|
the left gutter below (the article is no longer viewport-centered). */
|
||||||
body:has(.multicol) #content {
|
body:has(.multicol) #content {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr);
|
||||||
}
|
}
|
||||||
@@ -561,20 +562,28 @@ article dd {
|
|||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Multi-column reading, but only for long articles (pagerite.js adds
|
/* Multi-column reading, but only for long articles: the backend render
|
||||||
.multicol based on content length — code blocks excluded — and splits
|
splits the body into .colseg segments (separated by full-width h2s and
|
||||||
the body into .colseg segments separated by full-width h2s and .wide
|
.wide elements), tags segments with enough text as .cols (a ::: nocols
|
||||||
elements; only segments with enough text get .cols, and a ::: nocols
|
container opts its section out) and flags the article .multicol based
|
||||||
container opts its section out). No fixed breakpoint: `columns: 30rem` lets CSS fit as
|
on content length — code blocks excluded. Never more than two columns:
|
||||||
many columns of at least 30rem as the article's current width allows —
|
`columns: 30rem 2` fits one or two columns of at least 30rem into the
|
||||||
since .multicol also uncaps the article width (see #content above), a
|
article's current width — since .multicol also uncaps the article
|
||||||
wider window simply yields more columns. */
|
width (see #content above), a wider window widens the two columns
|
||||||
|
instead of adding more. */
|
||||||
.multicol .colseg.cols {
|
.multicol .colseg.cols {
|
||||||
columns: 30rem;
|
columns: 30rem 2;
|
||||||
column-gap: 3.5rem;
|
column-gap: 3.5rem;
|
||||||
column-rule: 1px solid var(--line);
|
column-rule: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 +603,8 @@ article dd {
|
|||||||
pre,
|
pre,
|
||||||
blockquote,
|
blockquote,
|
||||||
table,
|
table,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
dl,
|
dl,
|
||||||
.admonition,
|
.admonition,
|
||||||
.markdown-alert {
|
.markdown-alert {
|
||||||
@@ -723,13 +734,13 @@ 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: right); {.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}. All of them drop into the left
|
||||||
the box; multicol pages have no right gutter at all, and while editing
|
margin when the layout has room for it (the breakout rules live with
|
||||||
the docked panel reshapes the gutters — in all these it stays a plain
|
the figure rules below); without room they stay in-column floats —
|
||||||
float). Headings already clear floats, so asides never bleed into the
|
asides on the right, margin boxes on the left. Headings already clear
|
||||||
next section. */
|
floats, so boxes never bleed into the next section. */
|
||||||
.aside {
|
.aside {
|
||||||
float: right;
|
float: right;
|
||||||
clear: right;
|
clear: right;
|
||||||
@@ -743,15 +754,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 +930,52 @@ figure:has(img[width]) {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* {.margin} figures float left like {.left} ones — until the margin
|
||||||
|
breakout below pulls them into the left gutter. */
|
||||||
|
figure:has(.margin) {
|
||||||
|
float: left;
|
||||||
|
width: 30%;
|
||||||
|
max-width: 50%;
|
||||||
|
margin: 0.3rem 1em 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The left-margin breakout: margin boxes ({.margin} / ::: margin blocks,
|
||||||
|
::: aside, {.margin} figures) leave the text column for the left
|
||||||
|
gutter, hugging the article's left edge (a 12rem box on a 13.25rem
|
||||||
|
pull: the gutter plus main's padding). The sticky sidebar shares the
|
||||||
|
gutter and slides over them translucently — the same overlap full-bleed
|
||||||
|
.wide images get. Scoped to direct .body children: the backend render
|
||||||
|
keeps breakout blocks out of the column segments, and nested ones keep
|
||||||
|
the in-column float fallback. Room exists once the single-column
|
||||||
|
layout's symmetric gutters fit the box (≥104rem), and on multicol
|
||||||
|
pages whose 1fr left gutter fits it (≥65rem) — but never while editing
|
||||||
|
(the docked panel owns the left edge), and not below 102rem with a
|
||||||
|
sidebar (the fixed 12rem sidebar track, see #content, leaves no free
|
||||||
|
gutter there). */
|
||||||
|
@media (min-width: 104rem) {
|
||||||
|
body:not(.editing) .body>.margin,
|
||||||
|
body:not(.editing) .body>.aside,
|
||||||
|
body:not(.editing) .body>figure:has(.margin) {
|
||||||
|
float: left;
|
||||||
|
clear: left;
|
||||||
|
width: 12rem;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0.3rem 0 1rem -13.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 65rem) {
|
||||||
|
body:not(.editing):has(.multicol):not(:has(#sidebar)) .body>.margin,
|
||||||
|
body:not(.editing):has(.multicol):not(:has(#sidebar)) .body>.aside,
|
||||||
|
body:not(.editing):has(.multicol):not(:has(#sidebar)) .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
|
||||||
@@ -1036,9 +1097,9 @@ article h2 {
|
|||||||
|
|
||||||
/* 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
|
floated figures — .left/.right/.margin fall back to plain centered
|
||||||
(explicit img widths still shrink-wrap), while .wide keeps its full
|
figures (explicit img widths still shrink-wrap), while .wide keeps its
|
||||||
viewport bleed. */
|
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,7 +1164,8 @@ 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;
|
||||||
|
|||||||
@@ -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, dropping into the left margin on wide
|
||||||
section out of the column layout. A brace-attribute
|
viewports — the same margin breakout ``{.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 break into the left margin (their
|
||||||
|
# negative-margin breakout only works as a direct .body child, never from
|
||||||
|
# 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