diff --git a/docs/backend.md b/docs/backend.md index b043d66..1cb3995 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -20,7 +20,7 @@ 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). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages). Code fences take pandoc-style brace attributes on the info line (` ```{.python .wide #id key=val} ` — the first class is the language when no bare language word precedes the braces) as well as a trailing `{...}` line; both land on the `
`, the `` keeps only the language class.
 
-`render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `
` children) — h1/h2 headings, `.wide` blocks and margin-breakout blocks (`.margin`, `::: aside`) stand bare, the runs between them become `
` (plus `.cols` on segments with enough text in at least two paragraphs or one long enough to split across columns, `::: nocols` opting out; in column segments, paragraphs past `BREAKABLE_TEXT` visible characters are marked `.breakable` so they may split across columns), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation). +`render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `
` children) — h1/h2 headings and `.wide` blocks stand bare, the runs between them become `
` (margin-breakout boxes — `.margin`, `::: aside` — stay inside the segment at their anchor point; the CSS positions them out of flow into the side zone) (plus `.cols` on segments with enough text in at least two paragraphs or one long enough to split across columns, `::: nocols` opting out; in column segments, paragraphs past `BREAKABLE_TEXT` visible characters are marked `.breakable` so they may split across columns), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation). ## `views.py` diff --git a/docs/design-principles.md b/docs/design-principles.md index 1b99e8b..4caeb8e 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, admonitions and `::: name` containers — generic `
` 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 `
` — 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 on disk (`/files/`, RAM-cached uncompressed + zstd) by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/…`. Raster images (not GIF) and SVGs (rasterized) are recompressed via mediapreview: the original is kept as `{hash}.orig{ext}` (internal only, never served — it may carry EXIF data; SVG originals stay servable as `{hash}.svg`) while pages link the extension-less `/_f/{hash}` and the server picks from the derivatives (`{hash}.avif` / `{hash}.webp` / `{hash}.jpg`) by Accept header — a format only when listed explicitly (`image/avif` → AVIF, `image/webp` → WebP, otherwise JPEG), with `vary: accept`; an explicit extension in the URL pins the format. 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/… "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), `{.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, and space-separated at the end of a text line (`some text {.small}`) the braces likewise belong to the block — a space is what keeps them off an image or link ending the line, which keep their own directly-attached attrs; text size classes `{.small}` / `{.large}` / `{.huge}` (em-based) work on any block; 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. +- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `
` 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 are taken out of flow into 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 `
` — 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 on disk (`/files/`, RAM-cached uncompressed + zstd) by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/…`. Raster images (not GIF) and SVGs (rasterized) are recompressed via mediapreview: the original is kept as `{hash}.orig{ext}` (internal only, never served — it may carry EXIF data; SVG originals stay servable as `{hash}.svg`) while pages link the extension-less `/_f/{hash}` and the server picks from the derivatives (`{hash}.avif` / `{hash}.webp` / `{hash}.jpg`) by Accept header — a format only when listed explicitly (`image/avif` → AVIF, `image/webp` → WebP, otherwise JPEG), with `vary: accept`; an explicit extension in the URL pins the format. 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/… "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), `{.margin}` makes it a margin note, placed 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, and space-separated at the end of a text line (`some text {.small}`) the braces likewise belong to the block — a space is what keeps them off an image or link ending the line, which keep their own directly-attached attrs; text size classes `{.small}` / `{.large}` / `{.huge}` (em-based) work on any block; 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 @@ -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. 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, the sidebar gets its own track at every width — flexible, 12rem when space is tight and growing up to 150% (18rem) once the viewport has room beyond the article, the sidebar keeping its left side on the viewport's edge — and the track is the left lane instead: no in-article zone, the text lane runs fluid up to 86rem leaning on the viewport's right edge (surplus extends the left lane), and the boxes hang into the lane off the article's left border (growing leftward with it, up to 18rem), 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 in at least two paragraphs (or one long enough to split) with `.cols` — code blocks are excluded from that measure, a `::: nocols` container opts its whole section out, and column-filling paragraphs are marked `.breakable` so they may split across the column gap (shorter paragraphs stay whole). On wide single-column pages (≥104rem), margin boxes lean into the vacant left gutter as well, growing with it up to 18rem. +- 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, the sidebar gets its own track at every width — flexible, 12rem when space is tight and growing up to 150% (18rem) once the viewport has room beyond the article, the sidebar keeping its left side on the viewport's edge — and the track is the left lane instead: no in-article zone, the text lane runs fluid up to 86rem leaning on the viewport's right edge (surplus extends the left lane), and the boxes hang into the lane off the article's left border (growing leftward with it, up to 18rem), 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 and `.wide` elements (full-width separators, never inside columns); margin boxes stay inside the segment at their anchor point and the CSS takes them out of flow — absolutely positioned off the article's left border into the zone, the columns flowing through unaffected — tagging segments that hold enough text in at least two paragraphs (or one long enough to split) with `.cols` — code blocks are excluded from that measure, a `::: nocols` container opts its whole section out, and column-filling paragraphs are marked `.breakable` so they may split across the column gap (shorter paragraphs stay whole). On wide single-column pages (≥104rem), margin boxes lean into the vacant left gutter as well, growing with it up to 18rem. - 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 ed04b5a..63ed4d2 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -818,7 +818,7 @@ article dd { 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. */ + boxes are placed into it out of flow, and the text never moves. */ article.multicol { margin-inline: auto; max-width: 58rem; /* 42rem lane + 16rem zone */ @@ -827,32 +827,27 @@ article.multicol { @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 article children (the backend render keeps - margin blocks out of the column segments); nested ones keep the - in-column float fallback. */ + are taken out of flow and placed against the article's left edge — + the same region the nav sidebar overlays. The boxes stay in the + column segment at their anchor point (the backend render no longer + splits segments around them); absolute positioning off the article — + always position: relative — pins the horizontal side to the zone + regardless of any column layout inside, while the unset top keeps + the box at the vertical position where it occurs in the text. */ article.multicol>.colseg, article.multicol>h1, article.multicol>h2 { margin-left: 16rem; } - article.multicol>.margin, - article.multicol>.aside, - article.multicol>figure:has(.margin) { - float: left; - clear: left; + article.multicol .margin, + article.multicol .aside, + article.multicol figure:has(.margin) { + position: absolute; + left: 0; 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. */ - article.multicol>figure:has(.wide), - article.multicol>div.wide, - article.multicol>pre.wide { - clear: left; + margin: 0.3rem 0 0; } } @@ -896,11 +891,10 @@ article.multicol { margin-left: 0; } - body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.margin, - body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.aside, - body:has(#sidebar):has(.multicol):not(.editing) article.multicol>figure:has(.margin) { - float: left; - clear: left; + body:has(#sidebar):has(.multicol):not(.editing) article.multicol .margin, + body:has(#sidebar):has(.multicol):not(.editing) article.multicol .aside, + body:has(#sidebar):has(.multicol):not(.editing) article.multicol figure:has(.margin) { + position: absolute; /* Attached to the article's left border (1.25rem gap), hanging into the left lane and growing leftward with it: 12rem when the lane is tight, up to 150% (18rem) when the track or the surplus has room @@ -909,7 +903,8 @@ article.multicol { --box-w: min(18rem, var(--lane) + 100cqw - 100% - 1.25rem); width: var(--box-w); max-width: none; - margin: 0.3rem 0 1rem calc(-1.25rem - var(--box-w)); + left: calc(-1.25rem - var(--box-w)); + margin: 0.3rem 0 0; } } @@ -1093,14 +1088,17 @@ blockquote p + p { --admonition-color: var(--accent3); } -/* Side boxes: ::: aside is a muted floated box (consecutive asides stack - via clear: left); {.margin} / ::: margin is a plainer margin note, and - figures take {.margin} like {.left}. On multicol pages they float in - the composition's left side zone — or in the sidebar's track when the - layout reserves one (see the article section); on wide single-column - pages they lean into the left gutter (with the figure rules below); - otherwise they stay in-column left floats. Headings already clear - floats, so boxes never bleed into the next section. */ +/* Side boxes: ::: aside is a muted floated box; {.margin} / ::: margin + is a plainer margin note, and figures take {.margin} like {.left}. + Where the layout has room for a side zone — multicol pages, the + sidebar's track, the wide single-column gutter (see the article + section and the figure rules below) — the boxes are taken out of flow + and absolutely positioned into it, off the article's left border, each + at the vertical spot where it occurs in the text (boxes occurring + closer together than their heights may overlap — keep them apart); + otherwise they stay in-column left floats (consecutive floats stack + via clear: left). Headings already clear floats, so in-column boxes + never bleed into the next section. */ .aside { float: left; clear: left; @@ -1351,18 +1349,19 @@ figure:has(.margin) { /* 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). The box grows with the gutter up to 150% (18rem), its - right side 1.25rem off the article's left border. */ + plain floats). Out of flow like on multicol pages: the box hangs off + the article's left border, growing with the gutter up to 150% (18rem), + its right side 1.25rem off the border. */ @media (min-width: 104rem) { - body:not(.editing):not(:has(.multicol)) article>.margin, - body:not(.editing):not(:has(.multicol)) article>.aside, - body:not(.editing):not(:has(.multicol)) article>figure:has(.margin) { - float: left; - clear: left; + body:not(.editing):not(:has(.multicol)) article .margin, + body:not(.editing):not(:has(.multicol)) article .aside, + body:not(.editing):not(:has(.multicol)) article figure:has(.margin) { + position: absolute; --box-w: min(18rem, (100vw - 78rem) / 2 - 1.25rem); width: var(--box-w); max-width: none; - margin: 0.3rem 0 1rem calc(-1.25rem - var(--box-w)); + left: calc(-1.25rem - var(--box-w)); + margin: 0.3rem 0 0; } } diff --git a/pagerite/markdown.py b/pagerite/markdown.py index 26412af..d165878 100644 --- a/pagerite/markdown.py +++ b/pagerite/markdown.py @@ -25,14 +25,18 @@ schemes stay visible; manually labelled links are untouched), and ``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 ``
`` (tagged +segmented for the column layout — h1/h2 headings and ``.wide`` blocks +stand on their own, the runs between them are wrapped in +``
`` (tagged ``.cols`` when the segment holds enough text — COLS_TEXT — in at least COLS_PARAS paragraphs or one paragraph long enough to turn .breakable, unless a ``::: nocols`` container opts it out; in column segments, paragraphs past BREAKABLE_TEXT are marked -``.breakable`` so they may split across columns). The result carries +``.breakable`` so they may split across columns). Margin-breakout boxes +(``.margin``, ``::: aside``) stay inside the segment at their anchor +point; pagerite.css takes them out of flow (absolute, off the article's +left border, into the side zone), so the columns flow through as if the +box wasn't there. 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 @@ -268,8 +272,8 @@ def _container_attrs(state) -> None: 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). + render() see the classes (the ::: nocols opt-out, {.wide} + containers). """ for token in state.tokens: if token.type != "container_block_open": @@ -493,11 +497,12 @@ _PARA_OPEN_RE = re.compile(r"]") _PARA_RE = re.compile(r"]*)?)>(.*?)

", re.S) # 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 article children for that — the zone -# rules key off it — never inside a column). +# full-width separator that splits the column segments. Margin-breakout +# boxes (.margin/.aside) are NOT boundaries: they stay inside the segment +# at their anchor point, and CSS positions them absolutely out of the +# article's left border (the zone rules anchor off the article), so the +# column flow is unaffected. _WIDE = "wide" -_BREAKOUT = ("margin", "aside") class Rendered(NamedTuple): @@ -557,14 +562,10 @@ def _top_level_blocks(tokens: list) -> list[list]: 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
) the image's classes - count as the block's own.""" + _WIDE comment above): h1/h2 headings and anything carrying .wide.""" 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 @@ -572,9 +573,7 @@ def _is_boundary(block: list) -> bool: 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)) + return False def render( @@ -591,8 +590,8 @@ def render( same pipeline as an explicit one (first-h1 anchor treatment included). 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
. + (h1/h2 headings, .wide — see _is_boundary) are rendered bare, the runs + between them wrapped in
. A segment is tagged .cols when it holds enough text (COLS_TEXT) in at least two paragraphs (COLS_PARAS) or one breakable-length paragraph, and no ::: nocols container; its long paragraphs are marked .breakable;