diff --git a/docs/design-principles.md b/docs/design-principles.md index 2bf7f01..7dd2dbc 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -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.). - **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; tables and strikethrough from the default preset), 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 `
`. 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 `
` — with `
` when it has a title; images inline with text and raw `` HTML stay plain inline images. Positioning is by attribute classes: `![alt](/_f/….avif "Caption"){.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. Headings (h1/h2) clear floats, so images never overflow into the next section. +- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `
` wrappers, 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 `
` — 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 `
` — with `
` when it has a title; images inline with text and raw `` HTML stay plain inline images. Positioning is by attribute classes: `![alt](/_f/….avif "Caption"){.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. Headings (h1/h2) clear floats, so images never overflow into the next section. ## Page structure and navigation @@ -34,7 +34,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level ## 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. +- 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. - 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 diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index ba1d9e5..93ab9d0 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -419,7 +419,10 @@ main { article h1, article h2, -article h3 { +article h3, +article h4, +article h5, +article h6 { font-family: var(--font-heading); font-weight: 600; line-height: 1.25; @@ -439,13 +442,31 @@ article dl, article blockquote, article pre, article figure, -article table { +article table, +article h3, +article h4, +article h5, +article h6 { margin-top: 0; margin-bottom: 1rem; } -article h3 { - margin: 1.4rem 0 0.4rem; +/* Headings separate from the text above via a top margin on the sibling + combinator: a heading that is the first child of a container (e.g. the + top of a .colseg column segment) gets no gap, and browsers truncate the + margin at column breaks, so column tops stay aligned. */ +article h3, +article h4, +article h5, +article h6 { + margin-bottom: 0.4rem; +} + +article * + h3, +article * + h4, +article * + h5, +article * + h6 { + margin-top: 1.4rem; } /* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels, @@ -536,9 +557,10 @@ article dd { } /* Multi-column reading, but only for long articles (pagerite.js adds - .multicol based on content length and splits the body into .colseg segments - separated by full-width h2s and wide figures; only segments with enough - text get .cols). No fixed breakpoint: `columns: 30rem` lets CSS fit as + .multicol based on content length — code blocks excluded — and splits + the body into .colseg segments separated by full-width h2s and .wide + elements; only segments with enough text get .cols, and a ::: nocols + container opts its section out). No fixed breakpoint: `columns: 30rem` lets CSS fit as many columns of at least 30rem as the article's current width allows — since .multicol also uncaps the article width (see #content above), a wider window simply yields more columns. */ @@ -567,7 +589,9 @@ article dd { pre, blockquote, table, - dl { + dl, + .admonition, + .markdown-alert { break-inside: avoid; } } @@ -587,10 +611,11 @@ article a:hover { color: var(--accent); } -/* Blockquotes: inner paragraphs carry no margins (spacing comes from the - blockquote itself, bottom-only like everything else in articles). The - negative left margin pushes the bar out past the text edge, so quoted - text aligns with the surrounding paragraphs — same trick as code blocks. */ +/* Blockquotes: spacing comes from the blockquote itself (bottom-only like + everything else in articles); inner paragraphs keep only the gap between + them. The negative left margin pushes the bar out past the text edge, so + quoted text aligns with the surrounding paragraphs — same trick as code + blocks. */ blockquote { margin: 0 0 1rem -0.5rem; padding: 0 0 0 0.25rem; @@ -602,42 +627,128 @@ blockquote p { margin: 0; } -/* Admonitions (markdown !!! note/warning/...): a lightweight callout in - the blockquote idiom — accent bar and a faint wash, recolored per type. +blockquote p + p { + margin-top: 0.6rem; +} + +/* Admonitions (markdown !!! note/warning/...) and GitHub-style alerts + (> [!NOTE] ...): a lightweight callout in the blockquote idiom — accent + bar and a faint wash, recolored per type, with a type emoji on the + title. The negative left margin pushes bar and wash out past the text + edge so the inner text aligns with surrounding paragraphs — same trick + as blockquotes and code blocks (margin-left = border + padding-left). Bottom-only margins like everything else in articles; inner paragraphs carry no margins of their own. */ -.admonition { - margin: 0 0 1rem; +.admonition, +.markdown-alert { + margin: 0 0 1rem -1.15rem; padding: 0.4rem 0.9rem; border-left: 0.25rem solid var(--admonition-color, var(--accent)); border-radius: 0 0.3rem 0.3rem 0; background: color-mix(in srgb, var(--admonition-color, var(--accent)) 7%, transparent); } -.admonition> :last-child { +.admonition> :last-child, +.markdown-alert> :last-child { margin-bottom: 0; } -.admonition-title { +.admonition-title, +.markdown-alert-title { margin: 0 0 0.2rem; font-weight: 600; color: var(--admonition-color, var(--accent)); } +.admonition-title::before, +.markdown-alert-title::before { + padding-right: 0.35em; +} + +.admonition.note .admonition-title::before, +.markdown-alert-note .markdown-alert-title::before { + content: "ℹ️"; +} + +.admonition.tip .admonition-title::before, +.admonition.hint .admonition-title::before, +.markdown-alert-tip .markdown-alert-title::before { + content: "✨"; +} + +.admonition.important .admonition-title::before, +.markdown-alert-important .markdown-alert-title::before { + content: "❗"; +} + +.admonition.success .admonition-title::before { + content: "✅"; +} + +.admonition.warning .admonition-title::before, +.markdown-alert-warning .markdown-alert-title::before { + content: "⚠️"; +} + +.admonition.caution .admonition-title::before, +.markdown-alert-caution .markdown-alert-title::before { + content: "🔥"; +} + +.admonition.danger .admonition-title::before, +.admonition.failure .admonition-title::before { + content: "⛔"; +} + .admonition.tip, .admonition.important, .admonition.hint, -.admonition.success { +.admonition.success, +.markdown-alert-tip, +.markdown-alert-important { --admonition-color: var(--accent2); } .admonition.warning, .admonition.caution, .admonition.danger, -.admonition.failure { +.admonition.failure, +.markdown-alert-warning, +.markdown-alert-caution { --admonition-color: var(--accent3); } +/* Asides (::: aside): a floated side box in the floated-figure idiom; + consecutive asides stack (clear: right). On wide single-column pages it + leans into the empty right gutter (below 104rem the gutter cannot hold + the box; multicol pages have no right gutter at all, and while editing + the docked panel reshapes the gutters — in all these it stays a plain + float). Headings already clear floats, so asides never bleed into the + next section. */ +.aside { + float: right; + clear: right; + width: 30%; + max-width: 20rem; + margin: 0.3rem 0 1rem 1.2rem; + padding: 0.6rem 0.9rem; + font-size: 0.9rem; + color: var(--muted); + background: color-mix(in srgb, var(--accent) 6%, transparent); + border-radius: 0.3rem; +} + +.aside> :last-child { + margin-bottom: 0; +} + +@media (min-width: 104rem) { + body:not(.editing):not(:has(.multicol)) .aside { + width: 12rem; + margin-right: -13rem; + } +} + pre { overflow-x: auto; padding: 0.5rem 0.8rem; diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index c57c3ec..714a6dc 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -277,23 +277,32 @@ import "overlayscrollbars/overlayscrollbars.css"; }); // Multi-column layout only when there is enough text to justify it. - // Split the body into columned segments: h1s, h2s and wide figures are + // 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 && body.textContent.trim().length > 1800, + !!body && textLen(body) > 1800, ); if (body && article.classList.contains("multicol") && !body.querySelector(".colseg")) { - // h1s, h2s and anything holding a wide image are full-width - // separators + // 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.querySelector("img.wide") !== null; + || el.classList.contains("wide") + || el.querySelector(".wide") !== null; let seg = null; for (const el of [...body.children]) { if (isSeparator(el)) { @@ -309,9 +318,13 @@ import "overlayscrollbars/overlayscrollbars.css"; } } // Columns are per section: only segments with enough text get them, - // so a short ingress or a brief section stays single-column. + // 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.textContent.trim().length > 600); + s.classList.toggle( + "cols", + s.querySelector(".nocols") === null && textLen(s) > 600, + ); } } } diff --git a/pagerite/markdown.py b/pagerite/markdown.py index 3887edd..d03e90d 100644 --- a/pagerite/markdown.py +++ b/pagerite/markdown.py @@ -4,8 +4,17 @@ Raw HTML (including inline scripts) is passed through unfiltered: the single author is trusted. Extensions: tables and strikethrough (from the "default" preset), footnotes, definition lists, task lists, brace-attributes (`{.class width=300}` on any element, images in -particular) and admonitions (``!!! note Title`` with an indented body — -note/tip/warning/etc., the title optional). Bare URLs autolink (GFM), with +particular), admonitions (``!!! note Title`` with an indented body — +note/tip/warning/etc., the title optional) and GitHub-style alerts +(``> [!NOTE]`` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the +same callout styling). ``::: name`` opens a generic container rendered +as ``
`` and closed by a matching ``:::`` (nest by +giving the outer container more colons, e.g. `::::`); ``::: aside`` +floats as a side box beside the text 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 +block, e.g. a paragraph ending with ``{.wide}`` breaks out of the column +layout as a full-width element. Bare URLs autolink (GFM), with the ``https://`` scheme hidden in the link text (``http://`` and other schemes stay visible; manually labelled links are untouched), and ``H~2~O`` / ``x^2^`` give sub/superscripts. @@ -33,6 +42,8 @@ from markdown_it.common.utils import escapeHtml from markdown_it.renderer import RendererHTML from mdit_py_plugins.admon import admon_plugin from mdit_py_plugins.attrs import attrs_plugin +from mdit_py_plugins.attrs.parse import ParseError, parse as parse_attrs +from mdit_py_plugins.container import container_plugin from mdit_py_plugins.deflist import deflist_plugin from mdit_py_plugins.footnote import footnote_plugin from mdit_py_plugins.gfm_autolink import gfm_autolink_plugin @@ -107,6 +118,14 @@ def _unwrap_lone_figures(state) -> None: if child and child.type == "image": if (tokens[i - 1].type == "paragraph_open" and tokens[i + 1].type == "paragraph_close"): + # A lone image becomes a
(see _image_rule); block + # attrs on the paragraph (e.g. a trailing {.wide} line) move + # onto the image so they survive the unwrap. + for key, value in (tokens[i - 1].attrs or {}).items(): + if key == "class": + child.attrJoin("class", value) + else: + child.attrSet(key, value) tokens[i - 1].hidden = True tokens[i + 1].hidden = True @@ -149,6 +168,72 @@ def _shorten_autolinks(state) -> None: text.content = text.content.removeprefix("https://") +_CONTAINER_NAME_RE = re.compile(r"\s*[a-zA-Z][\w-]*\s*$") + + +def _container_render(self, tokens, idx, options, env): + """Render `::: name` containers as `
`.""" + token = tokens[idx] + if token.nesting == 1: + token.attrJoin("class", token.info.strip()) + return self.renderToken(tokens, idx, options, env) + + +def _block_attrs(state) -> None: + """Apply `{.class key=value}` on a block's last line to the block. + + The inline attrs plugin only covers attributes right after an image, + code span or link; this extends the same brace syntax to whole blocks, + e.g. a paragraph ending with a `{.wide}` line (no blank line between) + gets the `wide` class and thereby breaks out of the column layout. + A lone `{...}` paragraph applies to the previous block instead (this + is how headings take attributes, since a heading's next line always + starts a new paragraph). Runs before the typographer so quotes inside + attributes stay straight. + """ + tokens = state.tokens + for i, token in enumerate(tokens): + if token.type != "inline" or not token.children: + continue + text = token.children[-1] + if (text.type != "text" or not text.content.startswith("{") + or not text.content.endswith("}")): + continue + try: + _, attrs = parse_attrs(text.content.strip()) + except ParseError: + continue + standalone = len(token.children) == 1 + if not standalone and token.children[-2].type != "softbreak": + continue + # The target: the enclosing block for a trailing attrs line, or the + # previous same-level block for a standalone attrs paragraph. Never + # a hidden token (tight-list paragraphs render no tag to hold the + # attributes) — in that case leave the text untouched instead of + # silently swallowing it. + own = i - 1 # standalone: the attrs paragraph's own opening token + j = i - 1 + while j >= 0: + if (tokens[j].nesting == 1 and not tokens[j].hidden + and (not standalone or (j != own + and tokens[j].level == tokens[own].level))): + break + j -= 1 + if j < 0: + continue + for key, value in attrs.items(): + if key == "class": + tokens[j].attrJoin("class", value) + else: + tokens[j].attrSet(key, value) + if standalone: + tokens[own].hidden = True + token.children = [] + tokens[i + 1].hidden = True + else: + del token.children[-2:] + + md = ( MarkdownIt( "default", @@ -161,6 +246,8 @@ md = ( ) .use(attrs_plugin) .use(admon_plugin) + .use(container_plugin, "block", validate=lambda params, _markup: + bool(_CONTAINER_NAME_RE.fullmatch(params)), render=_container_render) .use(footnote_plugin) .use(deflist_plugin) .use(tasklists_plugin, enabled=True) @@ -169,6 +256,10 @@ md = ( .use(superscript_plugin) ) md.add_render_rule("image", _image_rule) +# GFM alerts (`> [!NOTE]` etc.), built into markdown-it-py's blockquote rule. +md.options["alerts"] = True +# Block attrs must be stripped before the typographer curlifies their quotes. +md.core.ruler.before("replacements", "block_attrs", _block_attrs) md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures) md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes) md.core.ruler.push("shorten_autolinks", _shorten_autolinks)