Serve built assets under /_/assets, admin at /_/admin

Reorganize the URL space so all machinery lives under /_/: the Vite
build (now with assetsDir: '', two ES-module entries and hashed shared
assets: style.css, pygments.css, banner.svg, fonts moved from
pagerite/static to frontend/src/assets) is served at /_/assets via
frontend.route(app, "/_/assets") with cached="/", and the admin
shell moves to /_/admin, leaving only "_" as a reserved top-level
slug. A /favicon.ico route serves the file Vite copies from
frontend/public. In dev, Vite proxies content pages and /_/admin to
the backend.

Editor changes: drop the noisy status line for a save-error indicator,
reconnect the WebSocket with exponential backoff, and re-establish the
connection from send() when it has dropped. The socket connects when
an editor is opened (they mount on pen click), not before.
This commit is contained in:
2026-08-16 16:13:12 +00:00
parent 36b250a15c
commit d5e40fba12
18 changed files with 205 additions and 137 deletions
+27 -25
View File
@@ -2,10 +2,11 @@
// Page editor: CodeMirror for Markdown, live server-rendered preview
// applied straight into the visible article, saving over one WebSocket
// (/_/api/ws/editor). Docked left of the article on the page itself
// (main.js openEditor) or standalone at /admin with its own preview pane.
// The socket reconnects automatically; unsaved text and pending saves
// survive a disconnect. Editor scroll drives the document scroll, keeping
// the rendered article at the cursor's position.
// (main.js openEditor) or standalone at /_/admin with its own preview pane.
// The socket connects when the editor is opened and reconnects with
// exponential backoff after a failure; unsaved text and pending saves
// survive a disconnect. Editor scroll drives the document scroll, keeping the
// rendered article at the cursor's position.
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
@@ -21,7 +22,7 @@ const emit = defineEmits(['close'])
const path = ref('')
const title = ref('')
const published = ref(true)
const status = ref('connecting…')
const saveError = ref('')
const previewHtml = ref('')
const previewHasH1 = ref(false)
const editorEl = ref(null)
@@ -33,6 +34,8 @@ let view = null
let savedResolve = null
let pendingSave = null
let reconnectTimer = null
let reconnectDelay = 2000
const MAX_RECONNECT_DELAY = 16000
let everConnected = false
let dirty = false
let syncingScroll = false
@@ -42,7 +45,17 @@ function currentPath() {
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
} else {
ensureConnected()
}
}
function ensureConnected() {
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
connect()
}
}
function normPath(p) {
@@ -67,9 +80,6 @@ function save() {
published: published.value,
}
pendingSave = msg
status.value = ws && ws.readyState === WebSocket.OPEN
? 'saving…'
: 'offline — will save on reconnect'
send(msg)
return new Promise((resolve) => { savedResolve = resolve })
}
@@ -102,9 +112,6 @@ async function uploadImage(file) {
const { path: stored } = await res.json()
const alt = name.replace(/\.[^.]+$/, '')
insertAtCursor(`![${alt}](${stored})`)
status.value = `uploaded ${name}`
} else {
status.value = `upload failed (${res.status})`
}
}
@@ -170,16 +177,15 @@ function onMessage(ev) {
setDocument(msg.markdown)
requestRender()
dirty = false // just loaded from the server, nothing unsaved
status.value = msg.exists ? '' : 'new page'
} else if (msg.type === 'html' && msg.path === path.value) {
previewIntoArticle(msg.html, msg.has_h1)
} else if (msg.type === 'saved') {
status.value = `saved ${new Date().toLocaleTimeString()}`
saveError.value = ''
pendingSave = null
savedResolve?.()
savedResolve = null
} else if (msg.type === 'error') {
status.value = `error: ${msg.detail}`
saveError.value = '⚠️ changes could not be saved'
}
}
@@ -217,7 +223,7 @@ function connect() {
)
ws.onmessage = onMessage
ws.onopen = () => {
status.value = ''
reconnectDelay = 2000
if (everConnected) {
// Reconnected: local text is authoritative — don't re-open (that
// would clobber the editor), just resync preview and pending saves.
@@ -229,9 +235,11 @@ function connect() {
everConnected = true
}
ws.onclose = () => {
status.value = 'offline — reconnecting…'
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(connect, 1500)
reconnectTimer = setTimeout(() => {
connect()
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}, reconnectDelay)
}
}
@@ -294,9 +302,9 @@ onUnmounted(() => {
/>
<button type="button" @click="fileInput.click()">image</button>
<button type="button" @click="saveAndClose">save</button>
<span class="status">{{ status }}</span>
<button v-if="!standalone" type="button" class="close" title="close" @click="close"></button>
</header>
<div v-if="saveError">{{ saveError }}</div>
<div class="panes">
<div ref="editorEl" class="editor" />
<div v-if="standalone" ref="previewEl" class="preview">
@@ -370,12 +378,6 @@ onUnmounted(() => {
color: var(--text);
}
.status {
color: var(--muted);
font-size: 0.85rem;
min-width: 5rem;
}
.panes {
flex: 1;
display: flex;
+41 -27
View File
@@ -25,7 +25,7 @@ const emit = defineEmits(['close'])
const path = ref('')
const banner = ref('')
const status = ref('connecting…')
const saveError = ref('')
const tree = ref([])
const fileInput = ref(null)
const bannerEl = ref(null)
@@ -33,6 +33,8 @@ const bannerEl = ref(null)
let ws = null
let pendingSave = null
let reconnectTimer = null
let reconnectDelay = 2000
const MAX_RECONNECT_DELAY = 16000
let everConnected = false
let view = null // CodeMirror for the banner HTML
let syncing = false // set while replacing the document programmatically
@@ -56,7 +58,17 @@ function normPath(p) {
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
} else {
ensureConnected()
}
}
function ensureConnected() {
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
connect()
}
}
// Debounce per key: text edits save while typing, without a request per
@@ -72,9 +84,6 @@ function debounce(key, fn, ms = 600) {
function save() {
const msg = { type: 'save', path: normPath(path.value), banner: banner.value }
pendingSave = msg
if (ws && ws.readyState !== WebSocket.OPEN) {
status.value = 'offline — will save on reconnect'
}
send(msg)
}
@@ -189,7 +198,6 @@ async function commitPending() {
if (!node) return
const slug = node.slug.trim().replace(/\/+/g, '')
if (!slug) {
status.value = 'a slug is needed'
return
}
const loc = locatePending(tree.value, '')
@@ -205,7 +213,7 @@ async function commitPending() {
}),
})
if (!res.ok) {
status.value = `create failed (${res.status})`
saveError.value = '⚠️ changes could not be saved'
return
}
// Place it exactly where the row was dropped: a fresh order key halfway
@@ -218,6 +226,8 @@ async function commitPending() {
: next ? next.order - 1
: 1
await postStructure({ path: newPath, order })
} else {
saveError.value = ''
}
pending.value = null
await refreshPages()
@@ -232,10 +242,11 @@ async function addContent(node) {
body: JSON.stringify({ title: node.title, markdown: '', published: node.published }),
})
if (res.ok) {
saveError.value = ''
await refreshPages()
navigate(node.path)
} else {
status.value = `failed (${res.status})`
saveError.value = '⚠️ changes could not be saved'
}
}
@@ -283,7 +294,11 @@ async function saveBrand() {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ brand: brand.value }),
})
if (!res.ok) status.value = `brand save failed (${res.status})`
if (res.ok) {
saveError.value = ''
} else {
saveError.value = '⚠️ changes could not be saved'
}
}
// Two-step delete (no dialogs): the first click arms the row's button for
@@ -306,18 +321,19 @@ function armRemove(node) {
async function removePage(node) {
const res = await fetch(`/_/api/pages/${node.path}`, { method: 'DELETE' })
if (res.ok) {
saveError.value = ''
refreshPages()
const p = node.path
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
// The current page was deleted — or reduced to a category that now
// redirects to its first child. Either way, re-render from the server.
if (node.children.length) loadPlain(path.value)
else { status.value = 'deleted'; navigate('') }
else navigate('')
} else {
loadPlain(path.value) // refresh menus
}
} else {
status.value = `delete failed (${res.status})`
saveError.value = '⚠️ changes could not be saved'
}
}
@@ -334,8 +350,12 @@ async function postStructure(op) {
headers: { 'content-type': 'application/json' },
body: JSON.stringify(op),
})
if (!res.ok) status.value = `structure change failed (${res.status})`
else loadPlain(path.value) // refresh menus and content from the server
if (res.ok) {
saveError.value = ''
loadPlain(path.value) // refresh menus and content from the server
} else {
saveError.value = '⚠️ changes could not be saved'
}
await refreshPages()
return res.ok
}
@@ -453,7 +473,6 @@ async function uploadBannerMedia(file) {
const name = file.name.replace(/[^\w.-]/g, '-')
const res = await fetch(`/_/api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (!res.ok) {
status.value = `upload failed (${res.status})`
return
}
const { path: stored } = await res.json()
@@ -464,7 +483,6 @@ async function uploadBannerMedia(file) {
setDocument(rest ? `${tag}\n${rest}` : tag)
previewBanner()
save()
status.value = `uploaded ${name}`
}
function onBannerPaste(ev) {
@@ -491,13 +509,12 @@ function onMessage(ev) {
// Overlay this page's own banner on the swapped region. Empty means
// inherited: the server-rendered region already shows the right one.
if (banner.value.trim()) previewBanner()
status.value = msg.exists ? '' : 'new page'
} else if (msg.type === 'saved') {
status.value = `saved ${new Date().toLocaleTimeString()}`
saveError.value = ''
pendingSave = null
refreshPages()
} else if (msg.type === 'error') {
status.value = `error: ${msg.detail}`
saveError.value = '⚠️ changes could not be saved'
}
}
@@ -515,7 +532,7 @@ function connect() {
)
ws.onmessage = onMessage
ws.onopen = () => {
status.value = ''
reconnectDelay = 2000
if (everConnected) {
// Reconnected: resend any save attempted while offline.
if (pendingSave) send(pendingSave)
@@ -525,9 +542,11 @@ function connect() {
everConnected = true
}
ws.onclose = () => {
status.value = 'offline — reconnecting…'
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(connect, 1500)
reconnectTimer = setTimeout(() => {
connect()
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}, reconnectDelay)
}
}
@@ -575,9 +594,9 @@ onUnmounted(() => {
<div class="editor-root overlay">
<header class="toolbar">
<span class="mode-label">site editor</span>
<span class="status">{{ status }}</span>
<button type="button" class="close" title="close" @click="close"></button>
</header>
<div v-if="saveError">{{ saveError }}</div>
<section class="block">
<label class="field">
@@ -647,11 +666,6 @@ onUnmounted(() => {
white-space: nowrap;
}
.status {
color: var(--muted);
font-size: 0.85rem;
}
/* Window-style close button, top right corner. */
.toolbar .close {
margin-left: auto;
+34
View File
@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0b0918"/>
<stop offset="0.6" stop-color="#241842"/>
<stop offset="1" stop-color="#3d2b6b"/>
</linearGradient>
<linearGradient id="aur1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#00d4c8" stop-opacity="0"/>
<stop offset="0.5" stop-color="#00d4c8" stop-opacity="0.7"/>
<stop offset="1" stop-color="#7c5cff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="aur2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#7c5cff" stop-opacity="0"/>
<stop offset="0.5" stop-color="#ff5c8a" stop-opacity="0.55"/>
<stop offset="1" stop-color="#ffd75c" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="1600" height="360" fill="url(#sky)"/>
<g fill="#ffffff">
<circle cx="120" cy="60" r="1.6" opacity="0.9"/>
<circle cx="300" cy="30" r="1.1" opacity="0.7"/>
<circle cx="470" cy="90" r="1.4" opacity="0.8"/>
<circle cx="640" cy="45" r="1" opacity="0.6"/>
<circle cx="820" cy="70" r="1.5" opacity="0.85"/>
<circle cx="990" cy="35" r="1.1" opacity="0.7"/>
<circle cx="1150" cy="85" r="1.6" opacity="0.9"/>
<circle cx="1320" cy="50" r="1" opacity="0.6"/>
<circle cx="1480" cy="95" r="1.3" opacity="0.8"/>
</g>
<path d="M0 190 Q 400 90 800 170 T 1600 150 V240 Q 1200 210 800 240 T 0 250 Z" fill="url(#aur1)"/>
<path d="M0 230 Q 400 140 800 210 T 1600 190 V280 Q 1200 250 800 280 T 0 290 Z" fill="url(#aur2)"/>
<path d="M0 300 Q 400 260 800 300 T 1600 290 V360 H0 Z" fill="#12101c"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
@font-face { font-family: 'Fraunces'; font-weight: 100 1000; font-style: normal; font-display: swap; src: url('fraunces.woff2') format('woff2'); }
@font-face { font-family: 'Literata'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('literata.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 300 700; font-style: normal; font-display: swap; src: url('firacode.woff2') format('woff2'); }
Binary file not shown.
Binary file not shown.
+86
View File
@@ -0,0 +1,86 @@
pre { line-height: 125%; }
td.linenos .normal { color: #6e7681; background-color: #0d1117; padding-left: 5px; padding-right: 5px; }
span.linenos { color: #6e7681; background-color: #0d1117; padding-left: 5px; padding-right: 5px; }
td.linenos .special { color: #e6edf3; background-color: #6e7681; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #e6edf3; background-color: #6e7681; padding-left: 5px; padding-right: 5px; }
pre code .hll { background-color: #6e7681 }
pre code { color: #E6EDF3 }
pre code .c { color: #8B949E; font-style: italic } /* Comment */
pre code .err { color: #F85149 } /* Error */
pre code .esc { color: #E6EDF3 } /* Escape */
pre code .g { color: #E6EDF3 } /* Generic */
pre code .k { color: #FF7B72 } /* Keyword */
pre code .l { color: #A5D6FF } /* Literal */
pre code .n { color: #E6EDF3 } /* Name */
pre code .o { color: #FF7B72; font-weight: bold } /* Operator */
pre code .x { color: #E6EDF3 } /* Other */
pre code .p { color: #E6EDF3 } /* Punctuation */
pre code .ch { color: #8B949E; font-style: italic } /* Comment.Hashbang */
pre code .cm { color: #8B949E; font-style: italic } /* Comment.Multiline */
pre code .cp { color: #8B949E; font-weight: bold; font-style: italic } /* Comment.Preproc */
pre code .cpf { color: #8B949E; font-style: italic } /* Comment.PreprocFile */
pre code .c1 { color: #8B949E; font-style: italic } /* Comment.Single */
pre code .cs { color: #8B949E; font-weight: bold; font-style: italic } /* Comment.Special */
pre code .gd { color: #FFA198; background-color: #490202 } /* Generic.Deleted */
pre code .ge { color: #E6EDF3; font-style: italic } /* Generic.Emph */
pre code .ges { color: #E6EDF3; font-weight: bold; font-style: italic } /* Generic.EmphStrong */
pre code .gr { color: #FFA198 } /* Generic.Error */
pre code .gh { color: #79C0FF; font-weight: bold } /* Generic.Heading */
pre code .gi { color: #56D364; background-color: #0F5323 } /* Generic.Inserted */
pre code .go { color: #8B949E } /* Generic.Output */
pre code .gp { color: #8B949E } /* Generic.Prompt */
pre code .gs { color: #E6EDF3; font-weight: bold } /* Generic.Strong */
pre code .gu { color: #79C0FF } /* Generic.Subheading */
pre code .gt { color: #FF7B72 } /* Generic.Traceback */
pre code .g-Underline { color: #E6EDF3; text-decoration: underline } /* Generic.Underline */
pre code .kc { color: #79C0FF } /* Keyword.Constant */
pre code .kd { color: #FF7B72 } /* Keyword.Declaration */
pre code .kn { color: #FF7B72 } /* Keyword.Namespace */
pre code .kp { color: #79C0FF } /* Keyword.Pseudo */
pre code .kr { color: #FF7B72 } /* Keyword.Reserved */
pre code .kt { color: #FF7B72 } /* Keyword.Type */
pre code .ld { color: #79C0FF } /* Literal.Date */
pre code .m { color: #A5D6FF } /* Literal.Number */
pre code .s { color: #A5D6FF } /* Literal.String */
pre code .na { color: #E6EDF3 } /* Name.Attribute */
pre code .nb { color: #E6EDF3 } /* Name.Builtin */
pre code .nc { color: #F0883E; font-weight: bold } /* Name.Class */
pre code .no { color: #79C0FF; font-weight: bold } /* Name.Constant */
pre code .nd { color: #D2A8FF; font-weight: bold } /* Name.Decorator */
pre code .ni { color: #FFA657 } /* Name.Entity */
pre code .ne { color: #F0883E; font-weight: bold } /* Name.Exception */
pre code .nf { color: #D2A8FF; font-weight: bold } /* Name.Function */
pre code .nl { color: #79C0FF; font-weight: bold } /* Name.Label */
pre code .nn { color: #FF7B72 } /* Name.Namespace */
pre code .nx { color: #E6EDF3 } /* Name.Other */
pre code .py { color: #79C0FF } /* Name.Property */
pre code .nt { color: #7EE787 } /* Name.Tag */
pre code .nv { color: #79C0FF } /* Name.Variable */
pre code .ow { color: #FF7B72; font-weight: bold } /* Operator.Word */
pre code .pm { color: #E6EDF3 } /* Punctuation.Marker */
pre code .w { color: #6E7681 } /* Text.Whitespace */
pre code .mb { color: #A5D6FF } /* Literal.Number.Bin */
pre code .mf { color: #A5D6FF } /* Literal.Number.Float */
pre code .mh { color: #A5D6FF } /* Literal.Number.Hex */
pre code .mi { color: #A5D6FF } /* Literal.Number.Integer */
pre code .mo { color: #A5D6FF } /* Literal.Number.Oct */
pre code .sa { color: #79C0FF } /* Literal.String.Affix */
pre code .sb { color: #A5D6FF } /* Literal.String.Backtick */
pre code .sc { color: #A5D6FF } /* Literal.String.Char */
pre code .dl { color: #79C0FF } /* Literal.String.Delimiter */
pre code .sd { color: #A5D6FF } /* Literal.String.Doc */
pre code .s2 { color: #A5D6FF } /* Literal.String.Double */
pre code .se { color: #79C0FF } /* Literal.String.Escape */
pre code .sh { color: #79C0FF } /* Literal.String.Heredoc */
pre code .si { color: #A5D6FF } /* Literal.String.Interpol */
pre code .sx { color: #A5D6FF } /* Literal.String.Other */
pre code .sr { color: #79C0FF } /* Literal.String.Regex */
pre code .s1 { color: #A5D6FF } /* Literal.String.Single */
pre code .ss { color: #A5D6FF } /* Literal.String.Symbol */
pre code .bp { color: #E6EDF3 } /* Name.Builtin.Pseudo */
pre code .fm { color: #D2A8FF; font-weight: bold } /* Name.Function.Magic */
pre code .vc { color: #79C0FF } /* Name.Variable.Class */
pre code .vg { color: #79C0FF } /* Name.Variable.Global */
pre code .vi { color: #79C0FF } /* Name.Variable.Instance */
pre code .vm { color: #79C0FF } /* Name.Variable.Magic */
pre code .il { color: #A5D6FF } /* Literal.Number.Integer.Long */
+695
View File
@@ -0,0 +1,695 @@
/* Shared styles for server-rendered pages and Vue components. */
@import url("./fonts/fonts.css");
@import url("./pygments.css");
:root {
color-scheme: dark;
--bg: #12101c;
--surface: #1b1830;
--text: #e8e6f2;
--muted: #9a94b8;
--accent: #00d4c8;
--accent2: #7c5cff;
--line: #ffffff1a;
/* Width of the docked editor panel (used both here for shifting the page
and in the Vue editor's own styles). */
--editor-w: min(46rem, 50vw);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: "Literata", Georgia, serif;
font-size: 1.05rem;
line-height: 1.65;
margin: 0;
background: var(--bg);
color: var(--text);
/* Full-bleed elements (.wide) size to 100vw, which counts the vertical
scrollbar; clip the few stray pixels instead of scrolling. */
overflow-x: clip;
/* Full height even on short pages: the footer sits at the bottom and the
docked editor (sized by #content) never collapses. */
min-height: 100vh;
display: flex;
flex-direction: column;
}
#content {
flex: 1;
}
::selection {
background: var(--accent2);
}
/* Full-width banner: image header with the brand and nav overlaid. */
#banner {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 11rem;
background: url("./banner.svg") center 40% / cover;
border-bottom: 1px solid var(--line);
}
/* Per-page banner content (img, styled div, canvas...) overlays the
default artwork; swapped along with #nav/#main on fetch-navigation. */
#page-banner {
position: absolute;
inset: 0;
overflow: hidden;
}
#page-banner>* {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
#brand,
#nav {
position: relative;
}
#brand {
font-family: "Fraunces", serif;
font-weight: 700;
font-size: 2.4rem;
text-decoration: none;
margin: auto 1.25rem 0;
padding-top: 1.5rem;
background: linear-gradient(90deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: none;
filter: drop-shadow(0 0 0.1rem #000);
}
/* Nav overlaid at the bottom of the banner */
#nav {
font-size: 1.3em;
text-shadow: 0 0 0.1em black;
padding: 0.35rem 1.25rem;
}
#nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 0.25rem 1.5rem;
flex-wrap: wrap;
align-items: baseline;
}
#nav a {
text-decoration: none;
color: var(--text);
}
#nav ul ul a {
color: var(--muted);
}
#nav a:hover {
color: var(--accent);
}
#nav span {
color: var(--muted);
font-size: 0.95rem;
}
#nav .current {
color: var(--accent);
font-weight: 500;
}
/* Sidebar + main row. A symmetric grid: the article column is sized by the
viewport alone (never by content), with equally sized flexible gutters
on both sides. The sidebar sits in the left gutter, so it appearing or
disappearing never shifts the article; the right gutter balances it. */
#content {
position: relative;
display: grid;
grid-template-columns: 1fr minmax(0, 78rem) 1fr;
/* The docked editor pushes the content (not the header) right. */
transition: margin-left 0.25s ease;
}
body.editing #content {
margin-left: var(--editor-w);
padding-left: 1rem;
/* gap between the docked editor and the content */
/* No overflow clipping here: .editor-host lives outside this box
(negative left), and .wide shrink-wraps to the remaining space. */
}
/* The sidebar's gutter space is needed by the editor instead. */
body.editing #sidebar {
display: none;
}
/* The editor host lives inside #content: it starts below the banner and
ends above the footer. The panel itself sticks to the viewport while
scrolling (but never taller than the content area). */
.editor-host {
position: absolute;
top: 0;
bottom: 0;
left: calc(0px - var(--editor-w));
width: var(--editor-w);
}
.editor-root.overlay {
position: sticky;
top: 0;
height: 100vh;
max-height: 100%;
background: var(--bg);
animation: editor-slide-in 0.25s ease;
}
.editor-root.overlay.closing {
transform: translateX(-100%);
transition: transform 0.25s ease;
}
@keyframes editor-slide-in {
from {
transform: translateX(-100%);
}
}
/* Site structure tree: while dragging, empty child lists appear as drop
zones so a page can be moved under a childless page. */
body.tree-dragging .treelist:empty {
min-height: 1.2rem;
outline: 1px dashed var(--line);
border-radius: 4px;
}
/* The banner's own pen: opens the site editor (banner + structure).
Qualified with `button` to beat the later .edit-link rule's left offset
(both classes apply to the same element). */
button.banner-edit-link {
position: absolute;
top: 0.6rem;
right: 1.25rem;
left: auto;
z-index: 10;
font: inherit;
border: none;
cursor: pointer;
opacity: 0.55;
background: none;
padding: 0;
}
.banner-edit-link:hover {
opacity: 1;
}
#sidebar {
grid-column: 1;
/* Pinned to the page's left edge (not the article's) and kept in view
while scrolling. Translucent + blurred rather than an opaque box, so
full-bleed .wide images can pass underneath without a hard edge. */
justify-self: start;
align-self: start;
position: sticky;
top: 0;
z-index: 1;
width: 12rem;
max-height: 100vh;
overflow-y: auto;
padding: 1rem 1rem 1rem 1.25rem;
border-radius: 0 0 0.5rem 0;
background: color-mix(in srgb, var(--bg) 75%, transparent);
backdrop-filter: blur(0.5rem);
}
#sidebar:empty {
display: none;
}
#sidebar ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.8em;
line-height: 1.0;
}
#sidebar a {
text-decoration: none;
color: var(--muted);
}
#sidebar a:hover {
color: var(--accent);
}
#sidebar .current {
color: var(--accent);
font-weight: 500;
}
main {
grid-column: 2;
/* No top padding: a leading wide image sits flush under the banner, and
text-first pages get their spacing from the h1's top margin instead. */
padding: 0 1.25rem 3rem;
}
article h1,
article h2,
article h3 {
font-family: "Fraunces", serif;
font-weight: 600;
line-height: 1.25;
}
article h1 {
font-size: 2.2rem;
margin: 2rem 0 1.2rem;
color: var(--accent);
}
/* Margin strategy: bottom-only inside articles. Top margins misalign
column tops and collapse unpredictably; spacing comes from below. */
article p,
article ul,
article ol,
article dl,
article blockquote,
article pre,
article figure {
margin-top: 0;
margin-bottom: 1rem;
}
article h3 {
margin: 1.4rem 0 0.4rem;
color: color-mix(in oklab, var(--accent2) 60%, var(--muted));
}
/* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels,
orange 🔸 on even. The marker occupies a 1em outdented box so wrapped
lines align. */
article ul {
list-style: none;
padding-inline-start: 1em;
}
article ul li::before {
content: "🔹";
display: inline-block;
margin-left: -1.3em;
width: 1.3em;
}
article ul ul li::before {
content: "🔸";
}
article ul ul ul li::before {
content: "🔹";
}
/* Task lists render emoji checkmarks (see markdown.py), no diamond. */
article .task-list-item::before {
content: none;
}
article {
position: relative;
}
.edit-link {
position: absolute;
top: 0.2rem;
/* In the left gutter, on the same side as the docked editor panel. */
left: -2.2rem;
z-index: 2;
/* stay above full-bleed .wide images */
font: inherit;
background: none;
border: none;
padding: 0;
cursor: pointer;
opacity: 0.7;
text-shadow: 0 0 0.1em black;
}
/* pagerite.js tucks the pen at the end of the article's first h1. */
article h1 .edit-link {
position: static;
font-size: 1.1rem;
vertical-align: 0.3em;
margin-left: 0.4rem;
}
.edit-link:hover {
opacity: 1;
}
article p,
article li,
article dd {
text-align: justify;
hyphens: auto;
}
/* Multi-column reading on wide displays, but only for long articles
(pagerite.js adds .multicol based on content length and splits the body
into .colseg segments separated by full-width h2s and wide figures;
only segments with enough text get .cols and thus columns). Columns only
reflow text inside the article; the article's width never changes. */
@media (min-width: 100rem) {
.multicol .colseg.cols {
columns: 2;
column-gap: 3.5rem;
column-rule: 1px solid var(--line);
}
}
.multicol .colseg {
margin-bottom: 1rem;
p,
li {
break-inside: avoid-column;
}
figure,
pre,
blockquote,
table,
dl {
break-inside: avoid;
}
}
article h2 {
font-size: 1.5rem;
margin: 2.2rem 0 0.6rem;
color: var(--accent2);
}
article a {
color: var(--accent);
text-decoration: none;
}
article a:hover {
text-decoration: underline;
}
/* Blockquotes: inner paragraphs carry no margins (spacing comes from the
blockquote itself, bottom-only like everything else in articles). The
negative left margin pushes the bar out past the text edge, so quoted
text aligns with the surrounding paragraphs — same trick as code blocks. */
blockquote {
margin: 0 0 1rem -0.5rem;
padding: 0 0 0 0.25rem;
border-left: 0.25rem solid var(--accent2);
color: var(--muted);
}
blockquote p {
margin: 0;
}
pre {
overflow-x: auto;
padding: 0.5rem 0.8rem;
/* Code text aligns with the surrounding paragraphs: the box extends
past them by its own padding. */
margin-left: -0.8rem;
margin-right: -0.8rem;
background: #ffffff09;
border-radius: 4px;
position: relative;
}
/* Inline code integrates with the text, no box of its own */
p code,
li code {
font-size: 0.85em;
}
/* Click-to-copy button (added by pagerite.js) */
.copy {
position: absolute;
top: 0.35rem;
right: 0.35rem;
font: inherit;
font-size: 0.75rem;
padding: 0.15rem 0.6rem;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
pre:hover .copy,
.copy:focus-visible {
opacity: 1;
}
.copy.copied {
color: var(--accent);
}
code {
font-family: "Fira Code", ui-monospace, monospace;
font-size: 0.88em;
}
table {
border-collapse: collapse;
}
th,
td {
border: 1px solid var(--line);
padding: 0.35rem 0.8rem;
}
/* Images and figures */
img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
figure {
margin: 0 0 1.5rem;
}
figcaption {
color: var(--muted);
font-size: 0.85rem;
margin-top: 0.4rem;
}
/* Captions of full-bleed images: centered and kept to a readable width. */
figure:has(.wide) figcaption {
max-width: 65ch;
margin-inline: auto;
text-align: center;
}
/* Positioning via brace-attribute classes: {.right}, {.left}, {.wide} */
figure:has(.right),
img.right {
float: right;
margin: 0.3rem 0 1rem 1.5rem;
max-width: 45%;
}
figure:has(.left),
img.left {
float: left;
margin: 0.3rem 1.5rem 1rem 0;
max-width: 45%;
}
/* .wide is full bleed: edge to edge of the viewport (or of the space left
of the docked editor). Centered on the article column — which is itself
centered in the available space — via margin-left: 50% + translateX(-50%).
The sidebar stacks above it (z-index + opaque background). */
figure:has(.wide),
img.wide {
display: block;
/* no inline strut/descender gaps around the image */
width: 100vw;
max-width: none;
margin-left: 50%;
margin-right: 0;
/* kill the UA figure margin, it overflowed the body */
transform: translateX(-50%);
}
/* A paragraph wrapping only a wide image must not add its line height. */
p:has(> img.wide:only-child) {
line-height: 0;
}
body.editing figure:has(.wide),
body.editing img.wide {
width: calc(100vw - var(--editor-w) - 1rem);
}
/* Scroll reveal (pagerite.js adds .reveal/.in; JS off = fully visible) */
.reveal {
opacity: 0;
translate: 0 14px;
transition:
opacity 0.6s ease,
translate 0.6s ease;
}
.reveal.in {
opacity: 1;
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.reveal {
opacity: 1;
translate: none;
transition: none;
}
}
.task-list-item {
list-style: none;
}
.footnote {
font-size: 0.85rem;
color: var(--muted);
}
/* The footer element is kept (the editor host ends above it) but currently
empty and zero-height. */
footer {
padding: 0;
}
/* Rotating-cube page transition, adapted from termotohtori.fi.
FRAGILE: do not tweak; the view-transition pseudo-tree is picky. */
::view-transition {
perspective: 1000px;
background: #000;
inset: 0;
}
::view-transition-group(root),
::view-transition-image-pair(root) {
transform-style: preserve-3d;
isolation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
mix-blend-mode: normal;
backface-visibility: hidden;
animation: none;
}
@keyframes group-rotate {
to {
transform: rotateY(-90deg);
}
}
@keyframes fade-out-a-bit {
to {
opacity: 0.5;
}
}
@keyframes fade-in-a-bit {
from {
opacity: 0.5;
}
}
::view-transition-group(root) {
transform-origin: 50% 50% -50vw;
animation: 300ms ease-in-out forwards group-rotate;
}
::view-transition-old(root) {
animation: 300ms ease-in-out forwards fade-out-a-bit;
}
::view-transition-new(root) {
transform-origin: 0 0;
transform: rotateY(90deg);
inset: 0 auto 0 100%;
animation: 300ms ease-in-out forwards fade-in-a-bit;
}
/* Reverse direction for browser back navigation (same geometry, mirrored). */
@keyframes group-rotate-back {
to {
transform: rotateY(90deg);
}
}
html.nav-back::view-transition-group(root) {
animation-name: group-rotate-back;
}
html.nav-back::view-transition-new(root) {
transform-origin: 100% 0;
transform: rotateY(-90deg);
inset: 0 100% 0 auto;
}
/* Same-section navigation: a plain crossfade instead of the cube. These
rules only override animation/geometry, leaving the FRAGILE block's
perspective and layering untouched. The old snapshot stays fully opaque
underneath while the new one fades in on top — never a dip to black. */
@keyframes nav-fade-in {
from {
opacity: 0;
}
}
html.nav-fade::view-transition-group(root) {
animation: none;
}
html.nav-fade::view-transition-old(root) {
animation: none;
}
html.nav-fade::view-transition-new(root) {
transform: none;
inset: 0;
animation: 200ms ease-in-out nav-fade-in;
}
+4 -2
View File
@@ -1,10 +1,12 @@
import "./assets/style.css";
// Pagerite editor entries. Two separate apps, mounted in their own
// dynamically created host divs inside the static document:
// - PageEditor ("page" mode): pen next to an article heading — Markdown
// editing with the preview rendered into the visible article.
// - SiteEditor ("site" mode): pen on the banner — banner HTML editing
// (previewed into the real banner) and the site structure tree.
// The standalone /admin shell (#app in the DOM) mounts PageEditor with the
// The standalone /_/admin shell (#app in the DOM) mounts PageEditor with the
// page selected by location hash, as a no-dynamic-import fallback.
import { createApp } from 'vue'
import PageEditor from './PageEditor.vue'
@@ -45,7 +47,7 @@ export function closeEditor() {
const shell = document.getElementById('app')
if (shell) {
// Standalone /admin shell: mount into it and follow the location hash.
// Standalone /_/admin shell: mount into it and follow the location hash.
host = shell
createApp(PageEditor, {
pagePath: location.hash.replace(/^#\/?/, '').replace(/\/$/, ''),
+259
View File
@@ -0,0 +1,259 @@
import "./assets/style.css";
// Fetch-navigation: swap dynamic regions (#nav, #main) instead of full
// page loads. Real <a href> links are used throughout, so this is pure
// progressive enhancement - without JS every link does a normal load.
//
// Also: scroll-reveal effects and code copy buttons. These need no
// support from the article itself and are re-applied after each swap.
(() => {
const REGIONS = ["page-banner", "nav", "sidebar", "main"];
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)");
let editorModule = null;
function runScripts(root) {
// Scripts inserted via DOM swapping do not execute; re-create them.
for (const old of root.querySelectorAll("script")) {
const s = document.createElement("script");
for (const a of old.attributes) s.setAttribute(a.name, a.value);
s.textContent = old.textContent;
old.replaceWith(s);
}
}
// --- Scroll reveal + code block copy buttons -------------------------
const observer = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add("in");
observer.unobserve(e.target);
}
}
}, { rootMargin: "0px 0px -8% 0px" });
function addCopyButtons(main) {
for (const pre of main.querySelectorAll("pre")) {
if (pre.querySelector(".copy")) continue;
const btn = document.createElement("button");
btn.className = "copy";
btn.type = "button";
btn.textContent = "copy";
btn.addEventListener("click", async () => {
const code = pre.querySelector("code");
await navigator.clipboard.writeText(
(code || pre).textContent.replace(/\n$/, ""),
);
btn.textContent = "copied";
btn.classList.add("copied");
setTimeout(() => {
btn.textContent = "copy";
btn.classList.remove("copied");
}, 1500);
});
pre.append(btn);
}
}
// Tuck the article edit pen at the end of the first h1 (which may come
// from the markdown itself). Re-runs when the editor replaces the
// previewed body, since that wipes elements inside it.
function placeEditPen() {
const article = document.querySelector("#main article");
const btn = article?.querySelector("button.edit-link");
// First visible h1: the title h1 may be display:none when the
// markdown owns its heading (editor preview state).
const h1 = [...(article?.querySelectorAll("h1") || [])]
.find((h) => h.offsetParent !== null);
if (btn && h1 && btn.parentElement !== h1) h1.append(btn);
}
addEventListener("pagerite:preview", placeEditPen);
function applyEffects() {
(window.requestIdleCallback || setTimeout)(preload);
const main = document.getElementById("main");
addCopyButtons(main);
placeEditPen();
// Multi-column layout only when there is enough text to justify it.
// Split the body into columned segments: h2s and wide figures are
// full-width separators and never go inside columns.
const article = main.querySelector("article");
if (article) {
const body = article.querySelector(".body");
article.classList.toggle(
"multicol",
!!body && body.textContent.trim().length > 1800,
);
if (body && article.classList.contains("multicol")
&& !body.querySelector(".colseg")) {
// h2s and anything holding a wide image are full-width separators
const isSeparator = (el) =>
el.tagName === "H2" || el.querySelector("img.wide") !== null;
let seg = null;
for (const el of [...body.children]) {
if (isSeparator(el)) {
seg = null;
body.append(el);
} else {
if (!seg) {
seg = document.createElement("div");
seg.className = "colseg";
body.append(seg);
}
seg.append(el);
}
}
// Columns are per section: only segments with enough text get them,
// so a short ingress or a brief section stays single-column.
for (const s of body.querySelectorAll(".colseg")) {
s.classList.toggle("cols", s.textContent.trim().length > 600);
}
}
}
if (reduceMotion.matches) return;
for (const el of main.querySelectorAll(
"h2, h3, figure, img, pre, blockquote, table, dl, .task-list-item",
)) {
if (!el.classList.contains("reveal")) {
el.classList.add("reveal");
observer.observe(el);
}
}
}
// --- Preloading ------------------------------------------------------
// Warm the HTTP cache with all linked pages and their resources, so
// navigation (and the cube transition) is instant. Pages carry ETags,
// so re-running this after each navigation revalidates cheaply (304)
// and picks up changed content and images.
function preload() {
const urls = new Set();
for (const a of document.querySelectorAll('#nav a[href^="/"], #main a[href^="/"]')) {
if (!a.pathname.startsWith("/_/admin")) urls.add(a.pathname);
}
for (const url of urls) {
if (url === location.pathname) continue;
fetch(url)
.then((r) => (r.ok ? r.text() : ""))
.then((html) => {
if (!html) return;
// Off-screen parse: load the page's images and other resources
const doc = new DOMParser().parseFromString(html, "text/html");
for (const img of doc.querySelectorAll("img")) {
const i = new Image();
i.src = img.src;
}
})
.catch(() => {});
}
}
// The path we are currently showing. location.pathname is unusable for
// this on popstate (it has already changed to the target); the editors
// signal their replaceState navigation with pagerite:preview.
let currentPath = location.pathname;
addEventListener("pagerite:preview", () => {
currentPath = location.pathname;
});
// --- Fetch navigation ------------------------------------------------
async function load(url, push = true, back = false) {
// Navigating with the editor open closes it; unsaved edits are lost
// (the region swap discards the previewed changes anyway).
if (document.body.classList.contains("editing")) {
editorModule?.then((m) => m.closeEditor());
}
let doc;
let finalUrl = url;
try {
const res = await fetch(url);
const type = res.headers.get("content-type") || "";
if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
// Section URLs redirect to their first child; reflect that.
if (res.redirected) finalUrl = res.url;
doc = new DOMParser().parseFromString(await res.text(), "text/html");
} catch {
location.href = url; // fall back to a normal navigation
return;
}
if (REGIONS.some((id) => !doc.getElementById(id))) {
location.href = url;
return;
}
const doit = () => {
for (const id of REGIONS) {
const el = document.getElementById(id);
el.replaceWith(document.importNode(doc.getElementById(id), true));
}
document.title = doc.title;
// Banners may contain scripts (canvas etc.), content pages may too.
runScripts(document.getElementById("page-banner"));
runScripts(document.getElementById("main"));
applyEffects();
};
// Rotating cube page transition (see the FRAGILE block in style.css);
// mirrored when navigating back through history. Navigation within the
// same top-level section crossfades instead, in either direction.
if (document.startViewTransition && !reduceMotion.matches) {
const seg = (u) => new URL(u, location.href).pathname.split("/")[1];
const fade = seg(finalUrl) === seg(currentPath);
const root = document.documentElement.classList;
root.toggle("nav-fade", fade);
root.toggle("nav-back", back && !fade);
document.startViewTransition(doit).finished.finally(() => {
root.remove("nav-fade", "nav-back");
});
} else {
doit();
}
currentPath = new URL(finalUrl, location.href).pathname;
if (push) history.pushState(null, "", finalUrl);
scrollTo(0, 0);
}
addEventListener("click", (ev) => {
if (ev.defaultPrevented || ev.button !== 0
|| ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
// Edit buttons toggle the editor panel docked on this page: load the
// Vue app on demand (with any extra styles) and mount it in place.
// Clicking the pen of the already-open editor closes it; clicking the
// other pen swaps the panel for the other editor type.
const editBtn = ev.target.closest("button.edit-link");
if (editBtn && editBtn.dataset.editorSrc) {
ev.preventDefault();
const mode = editBtn.dataset.editorMode || "page";
if (document.body.classList.contains("editing")
&& document.body.dataset.editorMode === mode) {
editorModule?.then((m) => m.closeEditor());
return;
}
for (const css of (editBtn.dataset.editorCss || "").split(",")) {
if (css && !document.querySelector(`link[href="${css}"]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = css;
document.head.append(link);
}
}
const path = location.pathname.replace(/^\/+|\/+$/g, "");
editorModule = import(/* @vite-ignore */ editBtn.dataset.editorSrc);
editorModule
.then((m) => m.openEditor(path, { mode }))
.catch(() => {});
return;
}
const a = ev.target.closest("a[href]");
if (!a || a.target || a.hasAttribute("download")) return;
const url = new URL(a.href, location.href);
if (url.origin !== location.origin) return;
// Same-page anchor links (footnotes etc.): let the browser handle them
if (url.pathname === location.pathname && url.hash) return;
if (url.pathname.startsWith("/_/")) return;
ev.preventDefault();
load(url);
});
addEventListener("popstate", () => load(location.href, false, true));
applyEffects();
})();
+22 -4
View File
@@ -5,19 +5,37 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
// Proxy content pages (/slug, /path/to/slug) to the FastAPI backend in dev.
// Excludes Vite internals (/@..., /src, /node_modules, /__...) and the
// backend's /_ prefix. /_/api and /_/f are handled by the fastapi-vue plugin;
// /_/admin is proxied explicitly below.
const CONTENT_PROXY = '^\\/(?!_|@|src|node_modules|__)(?:[^./?]+(?:\\/[^./?]+)*)?(?:\\?.*)?$'
// https://vite.dev/config/
export default defineConfig({
plugins: [
fastapiVue(),
fastapiVue({ paths: ["/_/api", "/_/f"] }),
vue(),
vueDevTools(),
],
server: {
proxy: {
"/_/admin": { target: backendUrl, changeOrigin: false },
[CONTENT_PROXY]: { target: backendUrl, changeOrigin: false },
},
},
build: {
// JS entry only: no index.html in the build (it would shadow our /),
// and a manifest so the backend can resolve hashed asset names.
// Emit hashed assets at the root of frontend-build so the backend can
// serve them under /_/assets/{file} without a nested /assets directory.
manifest: true,
assetsDir: '',
rollupOptions: {
input: fileURLToPath(new URL('./src/main.js', import.meta.url)),
input: {
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
},
},
},
})