Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd75a260b5 | ||
|
|
6058853341 | ||
|
|
9adc48479f | ||
|
|
864492b897 | ||
|
|
515c6e1435 | ||
|
|
daa1670653 | ||
|
|
e3fbce8ece |
@@ -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
|
||||
|
||||
|
||||
@@ -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: `{.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: `{.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
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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(``)
|
||||
// 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 = ``
|
||||
// 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></></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></></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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "").
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user