Compare commits

...
7 Commits
Author SHA1 Message Date
LeoVasanko fd75a260b5 Insert images and tables as block-level fresh lines
uploadImage and insertTable no longer inject at the cursor: on a
non-empty line (e.g. inside an existing image tag) the block goes on a
fresh blank-separated line after it, never into it.
2026-08-29 06:09:51 +00:00
LeoVasanko 6058853341 Recompress uploaded images to thumbnailed AVIF via mediapreview
PUT /_api/files now runs raster uploads through mediapreview.dispatch
(temp file for format routing: pyvips, ffmpeg for HEIC/HEIF/AVIF),
storing the untouched original as <hash>.orig<ext> and serving the
AVIF derivative <hash>.avif in links. SVG/GIF and failed conversions
fall back to plain <hash><ext> storage. FileStore.delete removes the
whole hash pair. Adds mediapreview[standard] dependency.
2026-08-29 05:59:37 +00:00
LeoVasanko 9adc48479f Scroll page on cursor move only when cursor leaves viewport
Cursor-driven editor→page scroll sync pinned the cursor's page position
at a fixed window height, so every cursor move dragged the page along.
Now the page scrolls only when the cursor's mapped position crosses a
viewport edge margin, and just enough to bring it back inside.
2026-08-29 05:08:52 +00:00
LeoVasanko 864492b897 Article editor toolbar: toggling fences/links, class pickers, sizes, Tab indent
- Image insert always adds an empty "" caption with the cursor inside.
- Fenced blocks (``` code, ::: aside) share one toggle: clicked inside
  one it is removed and the content selected; otherwise the selection
  (expanded to whole lines) is wrapped, cursor left on the opener line.
- Code button: inline wrap toggles (selection preserved, backtick runs),
  line-spanning selections make fenced blocks.
- Link button toggles: clicked inside [label](url) it unwraps.
- Block classes via two pickers (placement, AA size) with current-class
  indication and a "normal" reset; placement replaces ::: container
  names, fences get their own attribute line.
- New .small/.large/.huge text-size classes (0.7/1.5/3em); base body is
  exactly 1rem so the scale is uniform.
- .left/.right floats generalized from figures to any block.
- Toolbar polish: non-emoji glyphs, scaled-up symbols, borderless
  hover/active states, reordered (B/i and size last).
- Editor panel no longer closes on Escape.
- CodeMirror editors capture Tab/Shift-Tab for indent/dedent.
2026-08-29 05:04:01 +00:00
LeoVasanko 515c6e1435 Block attrs space-separated at end of a text line
_block_attrs now applies a trailing {...} to the block when it ends the
last text line after whitespace (some text {.small}), not only on a line
of its own; a space is what keeps the braces off an image/link ending the
line. Glued-to-text braces stay literal.
2026-08-29 05:03:30 +00:00
LeoVasanko daa1670653 Themeable selection color via --selection-bg, shared by page and CodeMirror
Nitro's orange accent selection fill clashed with accent-colored text.
Introduce --selection-bg (base: accent 30% mix, as before) used by both
::selection and the editors' selection layer; nitro overrides it with a
neutral grey. CodeMirror's selection needed a baseTheme with its exact
&light/&dark selectors to win, and is now hidden when unfocused like a
normal input.
2026-08-29 03:39:24 +00:00
LeoVasanko e3fbce8ece Fix slug input: free typing with live space→hyphen and lowercasing
Live-filtering through slugify ate hyphens and spaces (trailing hyphens
are stripped) and jumped the cursor to the end on mid-text edits. Now
only spaces→hyphens and lowercasing happen oninput (length-preserving,
cursor stays put); the full slugify runs at commit before the server
call, which keeps its own validation.
2026-08-29 03:14:57 +00:00
15 changed files with 691 additions and 97 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ Siblings order by the fractional `Node.order` key: a moved item gets a fresh key
## Files
Files are content-addressed (blake3[:12] + extension) and stored **on disk** under `<hostname>/files/` (path from `PAGERITE_FILES`), served at `/_f/{name}` with immutable caching; the `FileStore` in app.py caches every file in RAM, both uncompressed and zstd-compressed (the compressed copy only when smaller), so `/_f` answers both encodings without disk reads. Pages reference files by absolute `/_f/` URLs so hierarchy moves never break them. Pre-refactor databases kept the blobs in a `Data.files` kanta field; the kanta migration `pagerite/migrations.py::migrate_v1` writes them to disk on open and drops the field (removed from `Data`). Fetched favicons of external analytics sites live in the same store (see `docs/analytics.md`).
Files are content-addressed (blake3[:12] + extension) and stored **on disk** under `<hostname>/files/` (path from `PAGERITE_FILES`), served at `/_f/{name}` with immutable caching. Uploaded raster images (except SVG/GIF) are stored as a pair: the untouched original under `<hash>.orig<ext>` and a mediapreview-recompressed AVIF derivative (`<hash>.avif`, thumbnailed to `IMAGE_MAXSIZE` at `IMAGE_QUALITY`) which is the externally linked file; deleting either name removes the pair. the `FileStore` in app.py caches every file in RAM, both uncompressed and zstd-compressed (the compressed copy only when smaller), so `/_f` answers both encodings without disk reads. Pages reference files by absolute `/_f/` URLs so hierarchy moves never break them. Pre-refactor databases kept the blobs in a `Data.files` kanta field; the kanta migration `pagerite/migrations.py::migrate_v1` writes them to disk on open and drops the field (removed from `Data`). Fetched favicons of external analytics sites live in the same store (see `docs/analytics.md`).
## Banners
+1 -1
View File
@@ -20,7 +20,7 @@ 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 `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box and `{.margin}` / `::: margin` marks any block a margin note — on all but phone widths they float in the side zone at the article's left (the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored on disk (`<hostname>/files/`, RAM-cached uncompressed + zstd) 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: `![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), `{.margin}` makes it a margin note, floating in the side zone left of the text on all but phone widths, `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored on disk (`<hostname>/files/`, RAM-cached uncompressed + zstd) by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Raster images (not SVG/GIF) are recompressed via mediapreview: the original is kept as `/_f/{hash}.orig{ext}` while pages link the thumbnailed AVIF derivative `/_f/{hash}.avif`. 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: `![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), `{.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.
## Page structure and navigation
+2 -2
View File
@@ -6,12 +6,12 @@ The Vue editor is a single tabbed `EditorShell.vue` mounted in a host div create
The shell hosts four kept-alive tabs (ordered site-wide first — site, structure — then, after a visual break, the per-page tabs — article, banner):
- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, anchored at a fixed window height), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload, with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path.
- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, scrolling only when the cursor's page position leaves the viewport, with an edge margin), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload (always block-level on a fresh blank-separated line of its own — a cursor on a non-empty line, e.g. inside an existing image tag, inserts after that line, never into it; always with an empty `""` caption, cursor inside the quotes), toggling fences (` ``` ` code blocks and `::: aside` containers share the same machinery: clicked inside one they remove it and select the content, otherwise they wrap the selection or the cursor's line, keeping it selected), and `.left`/`.right`/`.wide`/`.margin` placement toggles plus `.small`/`.large`/`.huge` text-size toggles (brace attributes on the block at the cursor, mutually exclusive within each group; on `:::` containers a placement class replaces the container name instead — `::: aside``::: margin`), with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path.
- `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`.
- `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + page-transition selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`.
- `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row.
Media uploads everywhere use the image icon buttons (pasting into the editor works too). The article, banner and site-settings pens are shorthands that open the shell on the matching tab; once open, clicking a pen switches tabs (and retargets the editors to the current page) instead of closing/remounting. The close button in the tab bar closes the shell (Escape too); tabs have no close buttons of their own. Closing only HIDES the shell — the Vue app stays mounted, so page-editor state (unsaved text included) survives until a real page reload; the editor always follows the URL, so fetch-navigating with the shell open (or before re-opening it) retargets it to the new page — unsaved text is stashed per path for the session and restored when returning, cleared on save. Saving there is explicit (Ctrl+S) and refreshes the page regions in place. Admin panels never reload the page.
Media uploads everywhere use the image icon buttons (pasting into the editor works too). The article, banner and site-settings pens are shorthands that open the shell on the matching tab; once open, clicking a pen switches tabs (and retargets the editors to the current page) instead of closing/remounting. The close button in the tab bar closes the shell (deliberately NOT Escape — it fired too easily by accident); tabs have no close buttons of their own. Closing only HIDES the shell — the Vue app stays mounted, so page-editor state (unsaved text included) survives until a real page reload; the editor always follows the URL, so fetch-navigating with the shell open (or before re-opening it) retargets it to the new page — unsaved text is stashed per path for the session and restored when returning, cleared on save. Saving there is explicit (Ctrl+S) and refreshes the page regions in place. Admin panels never reload the page.
In-place page re-rendering shared by the banner/site/structure tabs lives in `swapdoc.js` (`runScripts`/`loadPlain`: fetch a page, swap the dynamic regions, replaceState). It also exports `dropPageCache`, which the editor tabs call after any save that can alter the rendered HTML of other pages (theme, headings, structure, banners, site brand/CSS, favicon). Dropping the cache while editing avoids re-fetching every page immediately; the public runtime re-preloads visible links once the editor panel closes.
+4
View File
@@ -4,6 +4,8 @@
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme'
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
@@ -261,6 +263,8 @@ onMounted(async () => {
doc: '',
extensions: [
basicSetup,
// Tab/Shift-Tab indent and dedent instead of moving focus.
keymap.of([indentWithTab]),
html(),
cmTheme,
cmHighlight,
+2 -6
View File
@@ -54,20 +54,16 @@ function onSwitchEvent(ev) {
// Closing the shell hides it but keeps it mounted (main.js); the tabs stay
// cached in KeepAlive the whole time, so no state is ever lost until a real
// page reload. On re-show each active tab re-applies its window title and
// preview via its own pagerite:editor-shown listener.
function onKeydown(ev) {
if (ev.key === 'Escape' && document.body.classList.contains('editing')) close()
}
// preview via its own pagerite:editor-shown listener. No Escape-to-close:
// it fired too easily by accident (e.g. dismissing an editor popup).
onMounted(() => {
document.body.dataset.editorMode = activeMode.value
addEventListener('pagerite:switch-editor', onSwitchEvent)
addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
removeEventListener('pagerite:switch-editor', onSwitchEvent)
removeEventListener('keydown', onKeydown)
})
</script>
+492 -41
View File
@@ -16,6 +16,8 @@
import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { cmHighlight, cmTheme } from './cmtheme'
import { dropPageCache, loadPlain } from './swapdoc'
@@ -131,11 +133,6 @@ function close() {
if (dirty.value) loadPlain(path.value)
}
function insertAtCursor(text) {
view.dispatch(view.state.replaceSelection(text))
view.focus()
}
async function uploadImage(file) {
if (!file) return
const name = file.name.replace(/[^\w.-]/g, '-')
@@ -143,7 +140,31 @@ async function uploadImage(file) {
if (res.ok) {
const { path: stored } = await res.json()
const alt = name.replace(/\.[^.]+$/, '')
insertAtCursor(`![${alt}](${stored})`)
// Always include an empty caption (""), cursor inside the quotes: a
// lone image with a title renders as a captioned figure, and an empty
// caption is as good as none.
const insert = `![${alt}](${stored} "")`
// Images are never inline: the image always goes on a fresh line of
// its own, blank-separated from other content. On a non-empty line —
// notably when the cursor sits inside an existing image tag — the new
// image goes AFTER that line, never into it.
const doc = view.state.doc
const line = doc.lineAt(view.state.selection.main.from)
const prevNonEmpty = line.number > 1 && doc.line(line.number - 1).text.trim()
const nextNonEmpty = line.number < doc.lines && doc.line(line.number + 1).text.trim()
let pos, text
if (line.text.trim()) {
pos = line.to
text = '\n' + insert + (nextNonEmpty ? '\n' : '')
} else {
pos = line.from
text = (prevNonEmpty ? '\n' : '') + insert + (nextNonEmpty ? '\n' : '')
}
view.dispatch({
changes: { from: pos, insert: text },
selection: { anchor: pos + text.indexOf(insert) + insert.length - 2 },
})
view.focus()
}
}
@@ -170,26 +191,165 @@ function wrapInline(mark) {
view.focus()
}
function insertCode() {
// On an empty line with no selection: a fenced code block, cursor inside.
// Otherwise an inline code wrap (toggling).
// --- Fenced blocks (``` code, ::: containers) ------------------------------
// Fences are never nested, and both kinds behave identically in the
// toolbar: clicked with the cursor/selection inside a fence of its kind,
// the button REMOVES the fence lines and selects the whole content;
// otherwise it wraps the selection — expanded to whole lines, so partial
// line selections and a bare cursor on a line count as that line — in a
// fence, keeping the content selected. A cursor on an empty line inserts
// an empty fence with the cursor inside.
// Find the fence block around a line range by parity (no nesting): an odd
// count of marker lines above the range means it is inside a block. The
// range's own first/last lines may be the fence lines themselves.
function enclosingFence(fromNo, toNo, markerRe) {
const doc = view.state.doc
const isFence = (n) => markerRe.test(doc.line(n).text.trimStart())
let above = 0
for (let n = 1; n < fromNo; n++) if (isFence(n)) above++
let openNo = null
if (above % 2 === 1) {
for (let n = fromNo - 1; n >= 1; n--) {
if (isFence(n)) { openNo = n; break }
}
} else if (isFence(fromNo)) {
openNo = fromNo
}
if (openNo === null) return null
for (let n = Math.max(toNo, openNo + 1); n <= doc.lines; n++) {
if (isFence(n)) return { open: doc.line(openNo), close: doc.line(n) }
}
return null
}
// Remove the fence block enclosing the selection, selecting its whole
// content. Returns true when there was one.
function removeEnclosingFence(markerRe) {
const doc = view.state.doc
const { from, to } = view.state.selection.main
const line = view.state.doc.lineAt(from)
if (from === to && !line.text.trim()) {
const fence = enclosingFence(doc.lineAt(from).number, doc.lineAt(to).number, markerRe)
if (!fence) return false
const { open, close } = fence
// One atomic replace: fences out, content stays where it lands.
const hasAfter = close.to < doc.length
const end = hasAfter ? close.to + 1 : doc.length
const content = hasAfter
? doc.sliceString(open.to + 1, close.from) // trailing newline kept
: doc.sliceString(open.to + 1, Math.max(open.to + 1, close.from - 1))
const head = content.endsWith('\n') ? content.length - 1 : content.length
view.dispatch({
changes: { from: open.from, to: end, insert: content },
selection: { anchor: open.from, head: open.from + Math.max(0, head) },
})
view.focus()
return true
}
// Wrap the selection — expanded to whole lines (a bare cursor counts as
// its line) — in a fence, cursor left at the end of the opener line. On
// an empty line with no selection, insert an empty fence with the cursor
// at the END of the opener line (no blank content line): for ``` a
// language word can be typed right away, for ::: the container name
// (aside) can be rewritten.
function wrapInFence(openText, closeText) {
const doc = view.state.doc
const { from, to } = view.state.selection.main
const fromLine = doc.lineAt(from)
if (from === to && !fromLine.text.trim()) {
view.dispatch({
changes: { from: line.from, to: line.to, insert: '```\n\n```' },
selection: { anchor: line.from + 4 },
changes: { from: fromLine.from, to: fromLine.to, insert: `${openText}\n${closeText}` },
selection: { anchor: fromLine.from + openText.length },
})
view.focus()
} else {
const bf = fromLine.from
// A selection ending exactly at a line start excludes that (possibly
// empty) line — only the selected lines go inside the fence.
let lastLine = doc.lineAt(to)
if (to === lastLine.from && to > from) lastLine = doc.line(lastLine.number - 1)
const bt = lastLine.to
view.dispatch({
changes: [
{ from: bt, insert: `\n${closeText}` },
{ from: bf, insert: `${openText}\n` },
],
// Cursor at the end of the opening fence line, selection cleared —
// a language word (or container name) can be typed right away.
selection: { anchor: bf + openText.length },
})
}
view.focus()
}
function insertCode() {
// Toggling, selection-preserving code helper:
// - inside a fenced block: remove the fences, content selected (above)
// - selection covering whole line(s) or spanning lines: fenced block
// - empty line, no selection: a fenced block, cursor inside
// - otherwise an inline wrap; the inner text stays selected both ways,
// and a repeated click removes the backtick run around it
const state = view.state
const doc = state.doc
const { from, to } = state.selection.main
const fromLine = doc.lineAt(from)
const toLine = doc.lineAt(to)
const inline = from !== to && fromLine.number === toLine.number
&& !(from === fromLine.from && to === toLine.to)
if (!inline && removeEnclosingFence(/^```/)) return
if (from === to) {
if (!fromLine.text.trim()) wrapInFence('```', '```')
else wrapInline('`')
return
}
wrapInline('`')
if (!inline) {
wrapInFence('```', '```')
return
}
// Inline: a matching backtick run on both sides unwraps; otherwise wrap
// (double ticks when the text itself contains a backtick).
let l = 0
while (l < from && doc.sliceString(from - l - 1, from - l) === '`') l++
let r = 0
while (doc.sliceString(to + r, to + r + 1) === '`') r++
if (l > 0 && l === r) {
view.dispatch({
changes: [{ from: to, to: to + r }, { from: from - l, to: from }],
selection: { anchor: from - l, head: to - l },
})
} else {
const mark = doc.sliceString(from, to).includes('`') ? '``' : '`'
view.dispatch({
changes: { from, to, insert: mark + doc.sliceString(from, to) + mark },
selection: { anchor: from + mark.length, head: to + mark.length },
})
}
view.focus()
}
function insertLink() {
// Selected text becomes the link label — or the URL if it looks like one.
// Toggle: with the cursor or selection anywhere inside an existing
// [label](url) on this line, unwrap it (the label stays selected).
// Otherwise the selected text becomes the label — or the URL if it
// looks like one.
const { from, to } = view.state.selection.main
const text = view.state.sliceDoc(from, to)
const doc = view.state.doc
const line = doc.lineAt(from)
const linkRe = /\[([^\]]*)\]\(([^)]*)\)/g
let m
while ((m = linkRe.exec(line.text))) {
if (line.text[m.index - 1] === '!') continue // image, not a link
const start = line.from + m.index
if (from >= start && to <= start + m[0].length) {
const label = m[1]
view.dispatch({
changes: { from: start, to: start + m[0].length, insert: label },
selection: { anchor: start, head: start + label.length },
})
view.focus()
return
}
}
const text = doc.sliceString(from, to)
const isUrl = /^https?:\/\/\S+$/.test(text)
const insert = isUrl ? `[](${text})` : `[${text}]()`
const urlStart = from + insert.length - 1 // inside the parens
@@ -200,24 +360,213 @@ function insertLink() {
view.focus()
}
// ::: aside container, toggling like code fences (shared machinery above):
// inside one it is removed (content selected); otherwise the selection —
// or the cursor's line — becomes the content, selected. The placement
// buttons below work on the ::: line itself.
function insertAside() {
if (!removeEnclosingFence(/^:::/)) wrapInFence('::: aside', ':::')
}
// Block placement classes: .left/.right float, .wide full bleed, .margin
// a margin note; plus the text size classes .small/.large/.huge. The
// button toggles the class in the brace attributes of the block at the
// cursor (figure/image line, paragraph, code fence); classes within one
// group are mutually exclusive. ::: containers are the exception: a
// placement class replaces the container name (::: margin, etc.), a size
// class takes braces (::: aside {.small}). A blank cursor line targets
// the block above (a trailing {...} line applies there).
const PLACEMENTS = ['left', 'right', 'wide', 'margin']
const SIZES = ['small', 'large', 'huge']
// Toggle .cls in a line's trailing brace attributes, preserving the other
// tokens (language, #id, other groups' classes) and the original spacing;
// returns the new text.
function toggleAttrClass(text, cls, group) {
const m = text.match(/(\s*)\{([^{}]*)\}(\s*)$/)
if (!m) {
// A lone image takes the braces directly attached, others spaced.
const tight = /^\s*!\[[^\]]*\]\([^)]*\)$/.test(text.trimEnd()) ? '' : ' '
return text.trimEnd() + tight + `{.${cls}}`
}
const tokens = m[2].trim() ? m[2].trim().split(/\s+/) : []
const tok = `.${cls}`
let next
if (tokens.includes(tok)) {
next = tokens.filter((t) => t !== tok)
} else {
next = tokens.filter((t) => !group.some((c) => t === `.${c}`))
next.push(tok)
}
const base = text.slice(0, m.index).trimEnd()
return next.length ? base + (m[1] || ' ') + `{${next.join(' ')}}` : base
}
// Locate where block classes live for the block at the cursor:
// { line } — trailing brace attributes on that line (paragraph, image,
// fence info line); { container } — a ::: container's opener line; or
// { fence, attrLine } — a code fence, whose classes live on a line of
// their own after the closing fence (attrLine null when not written yet).
// A blank cursor line targets the block above. Shared by the class
// toggles and the pickers' current-class indicator.
function classTarget() {
const doc = view.state.doc
let line = doc.lineAt(view.state.selection.main.head)
while (!line.text.trim() && line.number > 1) line = doc.line(line.number - 1)
if (!line.text.trim()) return null
// ``` fence context: an odd count of fence lines above means the cursor
// is inside the fence or on its closing fence.
let open = false
for (let n = 1; n < line.number; n++) {
if (doc.line(n).text.trimStart().startsWith('```')) open = !open
}
if (open) {
let n = line.number
while (n <= doc.lines && !doc.line(n).text.trimStart().startsWith('```')) n++
if (n > doc.lines) return null // unclosed fence — nothing to attach to
const fence = doc.line(n)
const after = fence.number < doc.lines ? doc.line(fence.number + 1) : null
return {
fence,
attrLine: after && /^\s*\{[^{}]*\}\s*$/.test(after.text) ? after : null,
}
}
// ::: container context: same parity (containers are not nested) — on
// the opener, inside, or on the closing fence.
let above = 0
for (let n = 1; n < line.number; n++) {
if (doc.line(n).text.trimStart().startsWith(':::')) above++
}
if (above % 2 === 1) {
for (let n = line.number - 1; n >= 1; n--) {
if (doc.line(n).text.trimStart().startsWith(':::')) {
return { container: doc.line(n) }
}
}
return null
}
if (/^\s*:::\s*\w/.test(line.text)) return { container: line }
return { line }
}
function togglePlacement(cls, group = PLACEMENTS) {
const t = classTarget()
if (!t) {
view.focus()
return
}
let line
if (t.container) {
// Placement replaces the container name (clicking the active one
// reverts to aside); sizes and other classes take brace attributes.
if (PLACEMENTS.includes(cls)) {
const m = t.container.text.match(/^(\s*:::\s*)(\w+)/)
const name = m[2] === cls ? 'aside' : cls
view.dispatch({
changes: { from: t.container.from, to: t.container.to, insert: `${m[1]}${name}` },
})
view.focus()
return
}
line = t.container
} else if (t.fence) {
if (!t.attrLine) {
view.dispatch({ changes: { from: t.fence.to, insert: `\n{.${cls}}` } })
view.focus()
return
}
line = t.attrLine
} else {
line = t.line
}
const text = toggleAttrClass(line.text, cls, group)
if (text !== line.text) {
view.dispatch({ changes: { from: line.from, to: line.to, insert: text } })
}
view.focus()
}
// The class set of the block at the cursor (names without the dot): brace
// tokens, plus the container name when it is a placement (::: margin).
function braceClasses(text) {
const m = text.match(/\{([^{}]*)\}\s*$/)
if (!m) return new Set()
return new Set(
m[1].split(/\s+/).filter((tok) => tok.startsWith('.')).map((tok) => tok.slice(1)),
)
}
function currentClasses() {
const t = classTarget()
if (!t) return new Set()
if (t.container) {
const s = braceClasses(t.container.text)
const name = t.container.text.match(/^\s*:::\s*(\w+)/)?.[1]
if (PLACEMENTS.includes(name)) s.add(name)
return s
}
if (t.fence) return t.attrLine ? braceClasses(t.attrLine.text) : new Set()
return braceClasses(t.line.text)
}
// Table size picker: a hover grid popup (cols × rows) under the toolbar.
const tablePicker = ref(false)
const tableSize = ref({ cols: 0, rows: 0 })
const TABLE_MAX_COLS = 8
const TABLE_MAX_ROWS = 6
// Class pickers: popup listing the block class toggles (placement ↔︎,
// text size AA), closed after applying. The block's current class of the
// group is marked; choosing "normal" (or the current class) removes it.
const classPicker = ref(null) // 'place' | 'size' | null
const activeClasses = ref(new Set())
function openClassPicker(which) {
classPicker.value = classPicker.value === which ? null : which
if (classPicker.value) activeClasses.value = currentClasses()
}
function isClassActive(cls, group) {
return cls === 'normal'
? !group.some((c) => activeClasses.value.has(c))
: activeClasses.value.has(cls)
}
function applyClass(cls, group) {
if (cls === 'normal') {
const cur = group.find((c) => activeClasses.value.has(c))
if (cur) togglePlacement(cur, group) // present → toggles off
} else {
togglePlacement(cls, group)
}
classPicker.value = null
}
function insertTable(cols, rows) {
// A GFM table on its own blank-separated block, first header cell
// selected.
const { from, to } = view.state.selection.main
const before = from > 0 && view.state.doc.sliceString(from - 1, from) !== '\n' ? '\n\n' : ''
// selected. Like images, a table is block-level: on a fresh line of its
// own — a cursor on a non-empty line (e.g. inside an image tag) inserts
// after that line, never into it.
const doc = view.state.doc
const line = doc.lineAt(view.state.selection.main.from)
const prevNonEmpty = line.number > 1 && doc.line(line.number - 1).text.trim()
const nextNonEmpty = line.number < doc.lines && doc.line(line.number + 1).text.trim()
const row = (cells) => `| ${cells.join(' | ')} |`
const table = `${before}${row(Array(cols).fill('column'))}\n`
const grid = `${row(Array(cols).fill('column'))}\n`
+ `${row(Array(cols).fill('---'))}\n`
+ `${Array(rows).fill(row(Array(cols).fill(''))).join('\n')}\n`
+ `${Array(rows).fill(row(Array(cols).fill(''))).join('\n')}`
let pos, text
if (line.text.trim()) {
pos = line.to
text = '\n' + grid + (nextNonEmpty ? '\n' : '')
} else {
pos = line.from
text = (prevNonEmpty ? '\n' : '') + grid + (nextNonEmpty ? '\n' : '')
}
const anchor = pos + text.indexOf(grid) + 2
view.dispatch({
changes: { from, to, insert: table },
selection: { anchor: from + before.length + 2, head: from + before.length + 8 },
changes: { from: pos, insert: text },
selection: { anchor, head: anchor + 6 },
})
tablePicker.value = false
view.focus()
@@ -343,9 +692,10 @@ function onEditorShown() {
//
// Editor → page follows the CURSOR, not the editor viewport: the cursor's
// fractional line (soft-wrap included, so moving inside a wrapped
// paragraph tracks smoothly) maps to its page position, shown at a fixed
// anchor height in the window — the cursor on the last line lands at the
// end of the page, no ramping needed. Only cursor/selection changes drive
// paragraph tracks smoothly) maps to its page position. The page only
// scrolls when that position leaves the viewport (with an edge margin),
// and then just enough to bring it back inside — cursor movement within
// view never drags the page along. Only cursor/selection changes drive
// this direction: editor wheel-scrolling repositions the text, not the
// page, which removes the scroll→scroll echo entirely.
// Page → editor anchors a viewport fraction that grows with page progress
@@ -398,8 +748,8 @@ function editorTopFor(line) {
return Math.min(max, block.top + (line - n) * block.height)
}
//: Window height fraction where the cursor's page position is shown.
const CURSOR_ANCHOR = 1 / 3
//: Edge margin (window height fraction) for cursor-driven page scrolls.
const CURSOR_MARGIN = 1 / 8
function syncWindowToEditor() {
if (syncingScroll || !view) return
@@ -407,7 +757,10 @@ function syncWindowToEditor() {
requestAnimationFrame(() => {
const pts = syncPoints()
if (pts) {
// The cursor's page position, shown at a fixed window height.
// Scroll the page only when the cursor's page position leaves the
// viewport (minus an edge margin): while it stays visible the page
// keeps its position, so cursor movement does not drag the page
// along; crossing an edge scrolls just enough to bring it back.
const pos = view.state.selection.main.head
const coords = view.coordsAtPos(pos)
if (coords) {
@@ -418,8 +771,14 @@ function syncWindowToEditor() {
? Math.max(0, Math.min(1, (docY - block.top) / block.height))
: 0
const line = view.state.doc.lineAt(pos).number + frac
const y = interp(pts, line, 0, 1) - CURSOR_ANCHOR * innerHeight
if (Math.abs(scrollY - y) > 1) scrollTo({ top: Math.max(0, y), behavior: 'instant' })
const y = interp(pts, line, 0, 1)
const margin = CURSOR_MARGIN * innerHeight
let target = null
if (y < scrollY + margin) target = y - margin
else if (y > scrollY + innerHeight - margin) target = y - innerHeight + margin
if (target !== null && Math.abs(scrollY - target) > 1) {
scrollTo({ top: Math.max(0, target), behavior: 'instant' })
}
}
}
requestAnimationFrame(() => { syncingScroll = false })
@@ -506,6 +865,8 @@ onMounted(() => {
doc: '',
extensions: [
basicSetup,
// Tab/Shift-Tab indent and dedent instead of moving focus.
keymap.of([indentWithTab]),
markdown(),
cmTheme,
cmHighlight,
@@ -589,17 +950,59 @@ onUnmounted(() => {
>💾</button>
</header>
<div class="format-bar">
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
<button type="button" title="italic" @click="wrapInline('*')"><i>I</i></button>
<button type="button" title="code (empty line: code block)" @click="insertCode"><code>&lt;/&gt;</code></button>
<button type="button" title="link" @click="insertLink">🔗</button>
<button type="button" class="code-btn" title="code — inline wrap, or a fenced block for line-spanning selections; click again to unwrap" @click="insertCode"><code>&lt;/&gt;</code></button>
<button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗</button>
<button
type="button"
title="table"
:class="{ active: tablePicker }"
@click="tablePicker = !tablePicker"
></button>
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼</button>
></button>
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼</button>
<button type="button" title="aside box (::: aside) — wraps the selection or the cursor's line; clicked inside one, removes it" @click="insertAside"></button>
<span class="picker">
<button
type="button"
title="block placement class"
:class="{ active: classPicker === 'place' }"
@click="openClassPicker('place')"
></button>
<span v-if="classPicker === 'place'" class="picker-pop">
<button
v-for="c in ['normal', ...PLACEMENTS]"
:key="c"
type="button"
:class="{ active: isClassActive(c, PLACEMENTS), normal: c === 'normal' }"
:title="c === 'normal'
? 'remove the block\'s placement class'
: `${c} on the block at the cursor`"
@click="applyClass(c, PLACEMENTS)"
>{{ c }}</button>
</span>
</span>
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
<button type="button" title="italic" @click="wrapInline('*')"><i>i</i></button>
<span class="picker">
<button
type="button"
title="text size class"
class="aa"
:class="{ active: classPicker === 'size' }"
@click="openClassPicker('size')"
><span>A</span>A</button>
<span v-if="classPicker === 'size'" class="picker-pop">
<button
v-for="c in ['small', 'normal', 'large', 'huge']"
:key="c"
type="button"
:class="{ active: isClassActive(c, SIZES), normal: c === 'normal' }"
:title="c === 'normal'
? 'remove the block\'s size class'
: `${c} on the block at the cursor`"
@click="applyClass(c, SIZES)"
>{{ c }}</button>
</span>
</span>
<div v-if="tablePicker" class="table-picker" @mouseleave="tableSize = { cols: 0, rows: 0 }">
<div class="tp-grid" :style="{ gridTemplateColumns: `repeat(${TABLE_MAX_COLS}, 1fr)` }">
<button
@@ -699,9 +1102,9 @@ onUnmounted(() => {
.format-bar button {
min-width: 1.7rem;
padding: 0.15rem 0.3rem;
padding: 0.05rem 0.2rem;
font: inherit;
font-size: 0.85rem;
font-size: 1.05rem;
color: var(--muted);
background: none;
border: 1px solid transparent;
@@ -709,10 +1112,58 @@ onUnmounted(() => {
cursor: pointer;
}
/* Hover and selected (active) states: text color alone, no borders. */
.format-bar button:hover,
.format-bar button.active {
color: var(--text);
border-color: var(--line);
}
/* The glyphs are small relative to the button boxes; scaling them up
(transform, so layout is unaffected) fills the empty space between
symbols. The code symbol is larger than the rest, so it scales less. */
.format-bar > button,
.picker > button {
transform: scale(1.5);
}
.format-bar > button.code-btn {
transform: scale(1.25);
}
/* Class pickers: a button opening a small popup of class toggles (like
the table picker), anchored under its own button. */
.picker {
position: relative;
display: flex;
}
/* The size icon: two capital As at different sizes. */
.aa span {
font-size: 0.65em;
}
.picker-pop {
position: absolute;
top: 100%;
left: 0;
z-index: 20;
display: flex;
gap: 0.15rem;
padding: 0.3rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 6px;
box-shadow: 0 4px 16px #0004;
}
.picker-pop button {
font-family: var(--font-code, monospace);
font-size: 0.85rem;
}
/* "normal" (the reset entry) reads as text, not a class name. */
.picker-pop button.normal {
font-family: inherit;
}
/* Table size picker: hover grid popup below the format bar; the hovered
+6
View File
@@ -5,6 +5,8 @@
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { css } from '@codemirror/lang-css'
import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme'
@@ -487,6 +489,8 @@ onMounted(async () => {
doc: '',
extensions: [
basicSetup,
// Tab/Shift-Tab indent and dedent instead of moving focus.
keymap.of([indentWithTab]),
css(),
cmTheme,
cmHighlight,
@@ -506,6 +510,8 @@ onMounted(async () => {
doc: '',
extensions: [
basicSetup,
// Tab/Shift-Tab indent and dedent instead of moving focus.
keymap.of([indentWithTab]),
html(),
cmTheme,
cmHighlight,
+8 -4
View File
@@ -106,7 +106,8 @@ function discardPending() {
async function commitPending() {
const node = pending.value
if (!node) return
// Empty slug: derive one from the title (transliterated to ASCII).
// The typed slug is slugified at commit; empty derives one from the
// title (transliterated to ASCII).
const slug = slugify(node.slug.trim()) || slugify(node.title)
if (!slug) {
return
@@ -216,10 +217,13 @@ function onTitleInput(node, ev) {
})
}
// The slug inputs are filtered as you type (StructureTree onSlugInput,
// see slugify.js); the server re-validates and its reason is shown.
// Slug inputs are typed freely (spaces become hyphens live, see
// StructureTree onSlugInput); the value is slugified here at commit
// (blur/Enter) before talking to the server, which re-validates (e.g.
// reserved names) and its reason is shown.
async function commitSlug(node, ev) {
const slug = ev.target.value.trim()
const slug = slugify(ev.target.value.trim())
ev.target.value = slug
if (slug === node.slug) return
const parent = node.path.split('/').slice(0, -1).join('/')
// Empty slug at top level = the front page (path "").
+13 -12
View File
@@ -34,16 +34,17 @@ const props = defineProps({
const handlers = inject('structureHandlers')
// Live-filter the slug inputs as they are typed (oninput): invalid
// characters are simply not accepted, spaces become hyphens and unicode
// folds to ASCII (see slugify.js). Existing rows commit on change, the
// pending row is v-modeled.
function onSlugInput(ev) {
ev.target.value = slugify(ev.target.value)
}
function onPendingSlugInput(element, ev) {
element.slug = slugify(ev.target.value)
// Slug inputs accept free typing; the only live rewrites are turning
// spaces into hyphens and lowercasing (both keep the length for ASCII,
// so the cursor stays put). Anything else (unicode folding, stripping,
// collapsing) is left for commit time, where the value is run through
// slugify before talking to the server (StructureEditor). `element` is
// the pending row (v-modeled), null for existing rows (plain :value
// binding, read back on commit).
function onSlugInput(element, ev) {
const v = ev.target.value.replace(/\s/g, '-').toLowerCase()
ev.target.value = v
if (element) element.slug = v
}
// Focus the title input of a fresh pending row.
@@ -113,7 +114,7 @@ function onEnd() {
class="edit slug-edit"
:placeholder="slugify(element.title)"
title="Slug (last path segment) — empty: derived from the title"
@input="onPendingSlugInput(element, $event)"
@input="onSlugInput(element, $event)"
@keyup.enter="handlers.commitPending()"
@keyup.esc="handlers.discardPending()"
/>
@@ -135,7 +136,7 @@ function onEnd() {
:value="element.slug"
placeholder="front page"
title="Slug (last path segment) — renames move the whole subtree. Empty at top level = front page"
@input="onSlugInput"
@input="onSlugInput(null, $event)"
@change="handlers.commitSlug(element, $event)"
/>
<span class="acts">
+51 -4
View File
@@ -18,6 +18,9 @@
/* Links stay quiet — mostly text color with a hint of the accent —
and light up to the full accent on hover. */
--link: color-mix(in oklab, var(--text) 50%, var(--accent));
/* Selection fill for page text and the CodeMirror editors; themes
override when the accent tint clashes with accent-colored text. */
--selection-bg: color-mix(var(--accent) 30%, transparent);
/* Code highlighting palette, consumed by pygments.css: complete light and
dark sets (background included), resolved by light-dark() from the
used color-scheme. A theme picks a set simply by declaring
@@ -71,10 +74,25 @@
}
::selection {
background: color-mix(var(--accent) 30%, transparent);
background: var(--selection-bg);
color: inherit;
}
/* Text size classes for any block ({.small} {.large} {.huge}, set via the
format bar or by hand): em units on the single 1rem base scale, so they
compose with the theme's typography. */
.small {
font-size: 0.7em;
}
.large {
font-size: 1.5em;
}
.huge {
font-size: 3em;
}
/* Links never underline — including SVG link text, which the UA stylesheet
underlines by default. */
a {
@@ -107,7 +125,10 @@ html {
body {
font-family: var(--font-body);
font-size: 1.05rem;
/* 1rem exactly: the text scale (headings, .small/.large/.huge) keys off
one base size — no per-context tweaking, or consistent sizing becomes
impossible. */
font-size: 1rem;
line-height: 1.65;
/* Tabular numerals wherever the active font supports them; avoids
numbers jumping in width as counters/values change. */
@@ -1277,6 +1298,24 @@ figure:has(.left) {
margin: 0.3rem 1em 1rem 0;
}
/* The same floats for other blocks: ::: left / ::: right containers
(rendered div.left/right), paragraphs, code fences, blockquotes and
tables all take the class directly ({.right} at the end of a
paragraph's last line, a trailing {.left} line after a fence, ...). */
:is(div, p, pre, blockquote, table).right {
float: right;
width: 30%;
max-width: 50%;
margin: 0.3rem 0 1rem 1em;
}
:is(div, p, pre, blockquote, table).left {
float: left;
width: 30%;
max-width: 50%;
margin: 0.3rem 1em 1rem 0;
}
/* An image with an explicit width attribute shrink-wraps instead: the
figure fits the image and, per the auto inline margins above, centers
in the column. Placed after the percentage widths above so it
@@ -1457,8 +1496,8 @@ article h2 {
/* Phones and other narrow viewports: single-column layout with the
sidebar lifted above the article as a wrapping link strip, and no
floats at all — .left/.right/.margin figures fall back to plain
centered figures (explicit img widths still shrink-wrap), margin boxes
floats at all — .left/.right/.margin figures and blocks fall back to
plain full-width (explicit img widths still shrink-wrap), margin boxes
go full width, while .wide keeps its full viewport bleed. */
@media (max-width: 48rem) {
@@ -1532,6 +1571,14 @@ article h2 {
margin: 0 auto 1.5rem;
}
/* Non-figure floated blocks flatten to plain full-width blocks too. */
:is(div, p, pre, blockquote, table):is(.right, .left) {
float: none;
width: auto;
max-width: none;
margin: 0 0 1rem;
}
/* Margin boxes go full width too — no room for side floats on a
phone. */
.aside,
+17 -3
View File
@@ -8,7 +8,7 @@ import { tags } from '@lezer/highlight'
// The base theme sets monospace on .cm-scroller, so the font must be set
// there, not on "&".
export const cmTheme = EditorView.theme({
const cmEditorTheme = EditorView.theme({
"&": {
backgroundColor: "var(--bg)",
color: "var(--text)",
@@ -33,11 +33,25 @@ export const cmTheme = EditorView.theme({
".cm-cursor": { borderLeftColor: "var(--text)" },
// basicSetup's active-line highlight assumes a dark theme.
".cm-activeLine": { backgroundColor: "transparent" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground":
{ backgroundColor: "var(--line)" },
"&.cm-focused": { outline: "none" },
})
// Selection color needs a baseTheme: only base themes support the
// &light/&dark selectors, and @codemirror/view's own selection rules use
// them — we must match its selectors exactly (equal specificity) and rely
// on mounting later to win. Focused: the page's --selection-bg (the base
// accents tint; themes may override it). Unfocused: hidden, like a normal
// input (CodeMirror greys it by default).
const cmSelection = EditorView.baseTheme({
"&light .cm-selectionBackground, &dark .cm-selectionBackground":
{ backgroundColor: "transparent" },
"&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, &dark.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":
{ backgroundColor: "var(--selection-bg)" },
})
// Exported as one extension so the editors just list `cmTheme`.
export const cmTheme = [cmEditorTheme, cmSelection]
export const cmHighlight = syntaxHighlighting(HighlightStyle.define([
{ tag: tags.heading, fontWeight: "600", color: "var(--accent)" },
{ tag: tags.strong, fontWeight: "700" },
+68 -8
View File
@@ -20,6 +20,7 @@ import os
import re
import shutil
import socket
import tempfile
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from datetime import UTC, datetime
@@ -42,6 +43,7 @@ from fastapi import (
from fastapi.responses import RedirectResponse, Response
from fastapi_vue import Frontend
from kanta import Kanta
from mediapreview import dispatch
from pydantic import BaseModel
from zstandard import ZstdCompressor
@@ -78,6 +80,11 @@ analytics_store = analytics.Store(ANALYTICS_PATH)
# files on disk under hash-prefixed names, cached in RAM, served at /_f/.
FILES_DIR = Path(os.getenv("PAGERITE_FILES", str(SITE_DIR / "files")))
# Uploaded raster images are thumbnailed to this size and recompressed to
# AVIF; the untouched original is kept alongside as ``<hash>.orig<ext>``.
IMAGE_MAXSIZE = 1920
IMAGE_QUALITY = 60
# Live WebSocket clients for the analytics stream.
_analytics_ws_clients: set[WebSocket] = set()
_analytics_broadcast_task: asyncio.Task | None = None
@@ -181,10 +188,14 @@ BUILD_DIR = Path(__file__).with_name("frontend-build")
frontend = Frontend(BUILD_DIR, spa=False, cached="/_assets/")
def _ext(orig: str) -> str:
"""Sanitized lowercase extension (with dot) of an original file name."""
return "".join(c for c in Path(orig).suffix.lower() if c.isalnum() or c == ".")
def _hash_name(body: bytes, orig: str) -> str:
"""Content-addressed file name: blake3 hash prefix + original extension."""
ext = "".join(c for c in Path(orig).suffix.lower() if c.isalnum() or c == ".")
return blake3.blake3(body).hexdigest()[:12] + ext
return blake3.blake3(body).hexdigest()[:12] + _ext(orig)
def _store_seed_file(markdown: str, banner: str, orig: str, body: bytes) -> tuple[str, str]:
@@ -350,9 +361,17 @@ class FileStore:
self._cache[name] = self._entry(body)
def delete(self, name: str) -> None:
self._cache.pop(name, None)
with suppress(FileNotFoundError):
(self.path / name).unlink()
"""Delete a file plus its derivative/original counterpart, if any.
An image upload is stored as a pair sharing the hash prefix
(``<hash>.orig.<ext>`` + ``<hash>.avif``); deleting either removes
both.
"""
stem = name.partition(".")[0]
for key in [k for k in self._cache if k.partition(".")[0] == stem]:
self._cache.pop(key, None)
with suppress(FileNotFoundError):
(self.path / key).unlink()
def __contains__(self, name: str) -> bool:
return name in self._cache
@@ -656,19 +675,60 @@ async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]:
return {"markdown": new_markdown}
def _to_avif(body: bytes, ext: str) -> bytes | None:
"""Recompress an image body to a thumbnailed AVIF via mediapreview's
dispatch (pyvips for common formats, ffmpeg for HEIC/HEIF/AVIF), or
None if the body is not a decodable image (stored as-is by the caller).
Dispatch needs a real file for format routing, so the body goes
through a temp file.
"""
with tempfile.NamedTemporaryFile(suffix=ext) as tmp:
tmp.write(body)
tmp.flush()
try:
avif, _resp = dispatch(
Path(tmp.name),
quality=IMAGE_QUALITY,
maxsize=IMAGE_MAXSIZE,
maxzoom=1,
)
except Exception:
return None
return avif
@app.put("/_api/files/{name}")
async def upload_file(name: str, request: Request) -> dict[str, str]:
"""Store an upload (image, video...) in the content-addressed store.
The stored name is a blake3 hash prefix + the original extension,
served immutable at "/_f/{name}"; returns {"path": "/_f/..."}.
Raster images are additionally recompressed with mediapreview: the
original goes to ``<hash>.orig<ext>`` (kept for reprocessing) while
pages link the thumbnailed AVIF derivative ``<hash>.avif``. SVGs and
GIFs are stored as-is (vector/animation would be lost). Other content
and failed conversions fall back to plain storage.
"""
if "/" in name or name in {".", ".."}:
raise HTTPException(400, "bad file name")
body = await request.body()
stored = _hash_name(body, name)
file_store.put(stored, body)
return {"path": f"/_f/{stored}"}
if not body:
raise HTTPException(400, "empty file")
ext = _ext(name)
digest = blake3.blake3(body).hexdigest()[:12]
avif = (
None
if ext in {".svg", ".gif"}
else await asyncio.to_thread(_to_avif, body, ext)
)
if avif is None: # not a decodable image: store the body as-is
stored = digest + ext
file_store.put(stored, body)
return {"path": f"/_f/{stored}"}
file_store.put(f"{digest}.orig{ext}", body)
file_store.put(f"{digest}.avif", avif)
return {"path": f"/_f/{digest}.avif"}
@app.delete("/_api/files/{name}", status_code=204)
+22 -11
View File
@@ -289,9 +289,11 @@ 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.
code span or link; this extends the same brace syntax to whole blocks.
A paragraph takes them at the end of its last line, either directly
(a trailing `{.wide}` line, no blank line between) or space-separated
at the end of the text (`some text {.small}`) — a space means the
braces belong to the block, not to an image or link before them.
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
@@ -302,18 +304,20 @@ def _block_attrs(state) -> None:
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("}")
):
if text.type != "text":
continue
m = re.search(r"(\{[^{}]*\})\s*$", text.content)
if not m:
continue
start = m.start(1)
if start and not text.content[start - 1].isspace():
continue # glued to the text — literal, or inline attrs
try:
_, attrs = parse_attrs(text.content.strip())
_, attrs = parse_attrs(m.group(1))
except ParseError:
continue
standalone = len(token.children) == 1
if not standalone and token.children[-2].type != "softbreak":
if not standalone and start == 0 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 —
@@ -347,8 +351,15 @@ def _block_attrs(state) -> None:
tokens[own].hidden = True
token.children = []
tokens[i + 1].hidden = True
else:
elif start == 0:
del token.children[-2:]
else:
# Braces space-separated at the end of a text line: strip them
# (a whitespace-only remainder means they were on a line of
# their own after all — drop the softbreak too).
text.content = text.content[:start].rstrip()
if not text.content and token.children[-2].type == "softbreak":
del token.children[-2:]
#: Minimum number of in-body h1/h2 headings for section anchors to be
+3 -4
View File
@@ -42,6 +42,9 @@
--font-body: var(--font-montserrat);
--font-heading: var(--font-literata);
--code-x-height: 0.517; /* Montserrat's x-height ratio */
/* Neutral grey selection instead of the accent tint: accent-colored
text (h2, links, markers) stays readable on it in both schemes. */
--selection-bg: #6664;
}
/* Dark scheme: same identity, but the page goes deep violet (never muddy
@@ -62,10 +65,6 @@
}
}
::selection {
background: var(--accent);
}
/* Any banner used is separated from page by a thick orange line */
#banner {
border-bottom: 4px solid var(--accent);
+1
View File
@@ -25,6 +25,7 @@ dependencies = [
"markdown-it-py>=4.2.0",
"maxminddb>=3.1.1",
"mdit-py-plugins>=0.6.1",
"mediapreview[standard]>=0.2.3",
"platformdirs>=4.11.5",
"pygments>=2.20.0",
"python-slugify>=8.0.4",