Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee22de060 | ||
|
|
9be1491f0e | ||
|
|
29c1dc64ed | ||
|
|
a27958117f | ||
|
|
56fd5f854d |
@@ -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 `<br>`. 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. 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 `<div class="name">` 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 `<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. 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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
||||
import { DAY } from './analytics/time.js'
|
||||
import { formatCount } from './analytics/format.js'
|
||||
import { formatCount, formatReadTime } from './analytics/format.js'
|
||||
import {
|
||||
TNODE_W,
|
||||
TNODE_H,
|
||||
@@ -151,7 +151,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
const fitsPill = (label, fontPx = 19) => label.length * 0.52 * fontPx <= TNODE_W - 16
|
||||
|
||||
const countLabel = (n) =>
|
||||
n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views)
|
||||
n.readSec ? `${formatCount(n.views)}×${formatReadTime(n.readSec)}` : formatCount(n.views)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -259,8 +259,8 @@ function sortByNav(root, navOrder) {
|
||||
walk(root)
|
||||
}
|
||||
|
||||
/** Compute median reading time per article in whole minutes. */
|
||||
function buildReadMinutes(visits) {
|
||||
/** Compute median reading time per article in seconds. */
|
||||
function buildReadSeconds(visits) {
|
||||
const times = {}
|
||||
for (const v of visits || []) {
|
||||
for (const [path, sec] of Object.entries(v.read || {})) {
|
||||
@@ -269,19 +269,19 @@ function buildReadMinutes(visits) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const minutes = {}
|
||||
const seconds = {}
|
||||
for (const [path, arr] of Object.entries(times)) {
|
||||
arr.sort((a, b) => a - b)
|
||||
const mid = Math.floor(arr.length / 2)
|
||||
const median =
|
||||
arr.length % 2 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2
|
||||
minutes[path] = Math.max(1, Math.round(median / 60))
|
||||
seconds[path] = Math.round(median)
|
||||
}
|
||||
return minutes
|
||||
return seconds
|
||||
}
|
||||
|
||||
/** Compute view counts, labels and hidden flags for each node. */
|
||||
function annotateNodes(nodes, viewsData, titles, readMinutes) {
|
||||
function annotateNodes(nodes, viewsData, titles, readSeconds) {
|
||||
const viewCount = (p) => {
|
||||
let n = 0
|
||||
for (const c of Object.values(viewsData?.[p] || {})) n += c
|
||||
@@ -290,7 +290,7 @@ function annotateNodes(nodes, viewsData, titles, readMinutes) {
|
||||
|
||||
for (const n of nodes) {
|
||||
n.views = viewCount(n.path)
|
||||
n.readMin = readMinutes[n.path] || 0
|
||||
n.readSec = readSeconds[n.path] || 0
|
||||
// Article title inside the pill (clipped at the pill border on
|
||||
// render), slug as fallback for pages missing from the site tree.
|
||||
n.label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : n.path.split('/').pop())
|
||||
@@ -895,13 +895,13 @@ export function buildTransitionGraph(data, pageTree, visits = [], dayScale = 1)
|
||||
const exits = collectExitPairs(data?.transitions)
|
||||
const navOrder = buildNavigationOrder(pageTree)
|
||||
const titles = buildTitleMap(pageTree)
|
||||
const readMinutes = buildReadMinutes(visits)
|
||||
const readSeconds = buildReadSeconds(visits)
|
||||
|
||||
if (!internal.length && !navOrder.size) return null
|
||||
|
||||
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
|
||||
sortByNav(root, navOrder)
|
||||
annotateNodes(nodes, data?.views, titles, readMinutes)
|
||||
annotateNodes(nodes, data?.views, titles, readSeconds)
|
||||
const { arcs, arcLeft } = layoutGroups(root)
|
||||
const placed = nodes.filter((n) => !n.hidden)
|
||||
const pairs = aggregatePairs(internal)
|
||||
|
||||
@@ -65,6 +65,11 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(var(--accent) 30%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Links never underline — including SVG link text, which the UA stylesheet
|
||||
underlines by default. */
|
||||
a {
|
||||
@@ -126,7 +131,10 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
height: 13rem;
|
||||
/* Height scales down proportionally on small screens: 13rem at 800px
|
||||
(50rem) viewport, shrinking with the smaller of viewport width/height
|
||||
(vmin) below that, floored at 8rem. */
|
||||
height: clamp(8rem, 26vmin, 13rem);
|
||||
box-sizing: content-box;
|
||||
background: linear-gradient(135deg, var(--surface), var(--bg));
|
||||
border-bottom: 1px solid var(--line);
|
||||
@@ -192,7 +200,9 @@ body {
|
||||
#brand {
|
||||
font-family: var(--font-brand);
|
||||
font-weight: 700;
|
||||
font-size: 2.4rem;
|
||||
/* Scales down proportionally on small screens, same curve as the banner
|
||||
height: 2.4rem at 800px, shrinking with vmin below that. */
|
||||
font-size: clamp(1.4rem, 4.8vmin, 2.4rem);
|
||||
text-decoration: none;
|
||||
/* One line always: pagerite.js shrinks the font size to fit instead of
|
||||
wrapping (the themed size is the maximum). */
|
||||
@@ -295,9 +305,10 @@ body.editing #sidebar {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: var(--editor-w);
|
||||
/* Above the sidebar and .edit-link while sliding in/out (the host now
|
||||
lives at the end of <body>, so it needs its own stacking level). */
|
||||
z-index: 3;
|
||||
/* Above the sidebar, .edit-link and the banner's top-right pens
|
||||
(z-index 10) while sliding in/out (the host now lives at the end of
|
||||
<body>, so it needs its own stacking level). */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.editor-root.overlay {
|
||||
@@ -408,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;
|
||||
@@ -428,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,
|
||||
@@ -525,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. */
|
||||
@@ -556,7 +589,9 @@ article dd {
|
||||
pre,
|
||||
blockquote,
|
||||
table,
|
||||
dl {
|
||||
dl,
|
||||
.admonition,
|
||||
.markdown-alert {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -576,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;
|
||||
@@ -591,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;
|
||||
@@ -931,6 +1053,17 @@ article h2 {
|
||||
gap: 0.15rem 0.9rem;
|
||||
}
|
||||
|
||||
/* The editor panel takes over the entire viewport: no space left for
|
||||
the banner or the page content (main.js pins its top to 0 at these
|
||||
widths). */
|
||||
body.editing #content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.editor-host {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
#content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -51,10 +51,15 @@ function setEditingClass(enable) {
|
||||
// the page: its top is the banner's bottom edge while the banner is visible
|
||||
// (= #content's top edge), and the viewport top once the banner has
|
||||
// scrolled away. The window keeps scrolling normally while editing.
|
||||
// Below 48rem the panel covers the entire viewport (pagerite.css), so its
|
||||
// top stays 0 regardless of the banner.
|
||||
const narrow = matchMedia('(max-width: 48rem)')
|
||||
function trackPanelTop() {
|
||||
const content = document.getElementById('content')
|
||||
if (host && content) {
|
||||
host.style.top = `${Math.max(0, content.getBoundingClientRect().top)}px`
|
||||
host.style.top = narrow.matches
|
||||
? '0px'
|
||||
: `${Math.max(0, content.getBoundingClientRect().top)}px`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-2
@@ -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 ``<div class="name">`` 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 <figure> (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 `<div class="name">`."""
|
||||
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)
|
||||
|
||||
@@ -32,11 +32,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Genuinely large solid brand with a soft blue shadow overlapping the
|
||||
artwork — conservative, but unmissable. */
|
||||
#brand {
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
(inlined by the backend into #page-banner), in neutral dark greys that
|
||||
follow the page's color scheme. */
|
||||
|
||||
/* Bezier-swept banner with wide orange stripes (inlined SVG), separated
|
||||
from the page by a straight orange blade. */
|
||||
#banner {
|
||||
border-bottom: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
/* Banner artwork dark tones: neutral greys in light mode (retinted to the
|
||||
page's violet family by the dark-scheme block below). */
|
||||
.nb-base {
|
||||
|
||||
@@ -63,13 +63,19 @@
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Any banner used is separated from page by a thick orange line */
|
||||
#banner {
|
||||
border-bottom: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
/* Oversized outlined brand, spilling off the banner edge: orange stroke,
|
||||
solid black fill. */
|
||||
#brand {
|
||||
font-size: 10rem;
|
||||
/* Scales down proportionally below ~1000px: 10rem at a 62.5rem viewport,
|
||||
shrinking with vmin (smaller of viewport width/height) below that. */
|
||||
font-size: clamp(2.5rem, 16vmin, 10rem);
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
@@ -19,11 +19,6 @@
|
||||
--font-heading: var(--font-fraunces);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Oversized tilted brand in the sky→violet gradient. */
|
||||
#brand {
|
||||
font-size: clamp(3.2rem, 9vw, 7.5rem);
|
||||
|
||||
@@ -57,11 +57,6 @@ body {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--sun);
|
||||
color: #3d5223;
|
||||
}
|
||||
|
||||
/* No separation between banner and page: the frame loses its background,
|
||||
border and shadow, and the artwork overflows into the document below,
|
||||
masked by a transparency gradient so it cross-fades into the body's fixed
|
||||
|
||||
Reference in New Issue
Block a user