Remove stale /_admin app and disable SPA fallback

- Remove the /_admin route, views.render_editor, and standalone mode from
  the Vue editor (main.js / PageEditor.vue)
- Remove /_admin dev proxy and preload exclusion
- Set vite appType to 'mpa' and delete frontend/index.html so unknown
  /_ paths return 404 instead of an empty Vue shell
- Update AGENTS.md and docs/design-principles.md
This commit is contained in:
2026-08-16 20:20:19 +00:00
parent 53d98f0583
commit 658dea77b9
9 changed files with 45 additions and 165 deletions
+13 -13
View File
@@ -20,9 +20,9 @@ not for the public pages. See `docs/design-principles.md` for the design.
- Avoid running the server yourself, ask the user to test
- `app.py` — the FastAPI app. FastAPI's built-in API docs are disabled
(`docs_url`/`redoc_url`/`openapi_url=None`) because `/docs` belongs to
our content. Our own routes (content pages, `/_api/...`, `/_f/...`,
`/_admin`) are registered BEFORE `frontend.route(app, "/")` is
called: fastapi-vue inserts its file routes at the position where
our content. Our own routes (content pages, `/_api/...`, `/_f/...`) are
registered BEFORE `frontend.route(app, "/")` is called: fastapi-vue
inserts its file routes at the position where
`route()` was called (during `load()` in the lifespan), so anything
defined earlier wins. The one exception is the content catch-all
`/{path:path}`, registered AFTER `frontend.route()` so that built
@@ -115,17 +115,17 @@ not for the public pages. See `docs/design-principles.md` for the design.
two pens swap the docked
panel for the other editor; clicking the open editor's own pen closes it. Normally dynamic-imported onto the content page by
pagerite.js when a 🖊️ edit link is clicked (the link carries
`data-editor-src`/`data-editor-css`/`data-editor-mode`); the `/_admin`
route (page selected by location hash) is the no-JS-import fallback shell
rendered by `views.render_editor` and keeps its own preview pane.
`data-editor-src`/`data-editor-css`/`data-editor-mode`).
In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`),
in prod from the hashed build assets resolved via
`frontend-build/.vite/manifest.json`. `vite.config.js` builds with
`manifest: true`, `assetsDir: '_/assets'` (so the build mirrors the URL
space; `frontend/public/favicon.ico` lands at the build root and is
served at `/favicon.ico`) and JS inputs (`src/main.js` and
`src/pagerite.js`) so no `index.html` ends up in the build (it would shadow
`/`). All outputs are ES modules. vite-plugin-fastapi.js has an
`frontend-build/.vite/manifest.json`. `vite.config.js` sets
`appType: 'mpa'` (no SPA fallback) and builds with `manifest: true`,
`assetsDir: '_/assets'` (so the build mirrors the URL space;
`frontend/public/favicon.ico` lands at the build root and is served at
`/favicon.ico`). JS inputs are `src/main.js` and `src/pagerite.js`; there
is no `index.html` source (it would shadow `/` and turn missing dev paths
into an empty Vue shell). All outputs are ES modules.
vite-plugin-fastapi.js has an
auto-upgrade marker — edit `vite.config.js`, not the plugin.
- `docs/` — design documentation.
@@ -172,7 +172,7 @@ not for the public pages. See `docs/design-principles.md` for the design.
- Keep dependencies minimal; add via `uv add` and mention it.
- The public URL space belongs to content (pretty slugs at root). Reserve
only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`, `/_admin`), plus
only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus
`/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits,
hyphens and underscores `[a-z0-9_-]` (the site editor filters input live
via `slugify.js`, built on the `transliteration` npm package — unicode
+4 -6
View File
@@ -10,9 +10,8 @@ evolves.
Python with **html5tagger**. There is no client-side templating or SPA for
the public site.
- **Vue only where interactivity demands it.** Small interactive islands
(editing tools mainly) may be Vue components, either mounted into specific
elements of the server-rendered pages or served as standalone apps
(e.g. an admin panel). The public reading experience has no scripting
(editing tools mainly) are Vue components mounted into specific elements of
the server-rendered pages. The public reading experience has no scripting
requirement.
- **Persistence via kanta.** Content is stored in an asyncio-friendly kanta
database. Rendering happens on the fly on each request — there are no
@@ -29,7 +28,7 @@ evolves.
directly at the site root; structured content may nest
(`/docs/design-principles`-style). The URL space is the author's, so
reserved prefixes must be kept few and deliberate: everything internal
lives under `/_` (`/_api/`, `/_f/`, `/_assets/`, `/_admin`). The only
lives under `/_` (`/_api/`, `/_f/`, `/_assets/`). The only
other reserved root path is `/favicon.ico`, served from the build.
Slugs are lowercase ASCII letters, digits, hyphens and underscores
(`[a-z0-9_-]`; input is transliterated and filtered as you type, and a
@@ -159,8 +158,7 @@ evolves.
reloads the page). The pens are `<button>`s wired up by `pagerite.js`
editing is an action, not a navigation. The editor's WebSocket
**reconnects automatically** with local text and pending saves preserved.
A standalone shell also exists at `/_admin#/path` with its own preview
pane. (All users are trusted authors for now; access control later with
(All users are trusted authors for now; access control later with
SSO.)
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published
controls.
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pagerite</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+11 -63
View File
@@ -1,13 +1,12 @@
<script setup>
// 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.
// (/_api/ws/editor). Docked left of the article on the page itself.
// 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 { onMounted, onUnmounted, ref } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { markdown } from '@codemirror/lang-markdown'
@@ -15,7 +14,6 @@ import { cmHighlight, cmTheme } from './cmtheme'
const props = defineProps({
pagePath: { type: String, default: '' },
standalone: { type: Boolean, default: false },
})
const emit = defineEmits(['close'])
@@ -23,10 +21,7 @@ const path = ref('')
const title = ref('')
const published = ref(true)
const saveError = ref('')
const previewHtml = ref('')
const previewHasH1 = ref(false)
const editorEl = ref(null)
const previewEl = ref(null)
const fileInput = ref(null)
let ws = null
@@ -40,10 +35,6 @@ let everConnected = false
let dirty = false
let syncingScroll = false
function currentPath() {
return location.hash.replace(/^#\/?/, '').replace(/\/$/, '')
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
@@ -87,10 +78,9 @@ function save() {
async function saveAndClose() {
await save()
// Reload so nav/sidebar changes apply, then the editor is gone.
// In standalone mode replace the current history entry so the admin
// shell does not remain in the back-button stack.
if (props.standalone) location.replace(`/${path.value}`)
else { dirty = false; emit('close'); location.reload() }
dirty = false
emit('close')
location.reload()
}
function close() {
@@ -98,7 +88,7 @@ function close() {
const stale = dirty
dirty = false
emit('close')
if (stale && !props.standalone) location.reload()
if (stale) location.reload()
}
function insertAtCursor(text) {
@@ -128,11 +118,6 @@ function setDocument(text, preserveSelection = false) {
view.dispatch(tr)
}
function onHashChange() {
const p = currentPath()
if (p !== path.value) openPath(p)
}
function runScripts(root) {
// Scripts injected via innerHTML do not execute; re-create them.
for (const old of root.querySelectorAll('script')) {
@@ -144,15 +129,6 @@ function runScripts(root) {
}
function previewIntoArticle(html, hasH1) {
// Docked mode previews into the article on the page itself;
// standalone mode has its own preview pane. When the markdown owns its
// h1, the title-derived h1 is hidden (matching server-side rendering).
previewHasH1.value = hasH1
if (props.standalone) {
previewHtml.value = html
nextTick(() => { if (previewEl.value) runScripts(previewEl.value) })
return
}
const article = document.querySelector('#main article')
if (!article) return
const h1 = article.querySelector('h1')
@@ -210,13 +186,8 @@ function syncScroll() {
const scroller = view.scrollDOM
const max = scroller.scrollHeight - scroller.clientHeight
const pct = max > 0 ? scroller.scrollTop / max : 0
if (props.standalone) {
const pv = previewEl.value
if (pv) pv.scrollTop = pct * (pv.scrollHeight - pv.clientHeight)
} else {
const doc = document.documentElement
window.scrollTo(0, pct * (doc.scrollHeight - innerHeight))
}
const doc = document.documentElement
window.scrollTo(0, pct * (doc.scrollHeight - innerHeight))
syncingScroll = false
})
}
@@ -234,7 +205,7 @@ function connect() {
requestRender()
if (pendingSave) send(pendingSave)
} else {
openPath(props.standalone ? currentPath() : normPath(props.pagePath))
openPath(normPath(props.pagePath))
}
everConnected = true
}
@@ -281,7 +252,6 @@ onMounted(() => {
setMarkdown: (text) => setDocument(text, true),
path: () => path.value,
}
if (props.standalone) addEventListener('hashchange', onHashChange)
addEventListener('keydown', onKeydown)
})
@@ -293,13 +263,12 @@ onUnmounted(() => {
}
view?.destroy()
delete window.__pageritePageEditor
if (props.standalone) removeEventListener('hashchange', onHashChange)
removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div class="editor-root" :class="{ overlay: !standalone }">
<div class="editor-root overlay">
<header class="toolbar">
<input v-model="title" placeholder="Title" class="title" @input="requestRender" />
<label><input v-model="published" type="checkbox" /> published</label>
@@ -312,18 +281,11 @@ onUnmounted(() => {
/>
<button type="button" @click="fileInput.click()">image</button>
<button type="button" @click="saveAndClose">save</button>
<button v-if="!standalone" type="button" class="close" title="close" @click="close"></button>
<button 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">
<article>
<h1 v-if="!previewHasH1">{{ title }}</h1>
<!-- server-rendered markdown preview -->
<div class="body" v-html="previewHtml" />
</article>
</div>
</div>
</div>
</template>
@@ -418,18 +380,4 @@ onUnmounted(() => {
.editor :deep(.cm-gutters) {
display: none;
}
.preview {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 1rem 1.5rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.preview article {
max-width: 44rem;
margin: 0 auto;
}
</style>
+6 -23
View File
@@ -4,8 +4,6 @@
// 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
// page selected by location hash, as a no-dynamic-import fallback.
if (import.meta.env.DEV) {
import("./assets/pagerite.css");
import("./assets/themes/purple/theme.css");
@@ -17,22 +15,18 @@ import SiteEditor from './SiteEditor.vue'
let host = null
export function openEditor(path, { standalone = false, mode = 'page' } = {}) {
export function openEditor(path, { mode = 'page' } = {}) {
closeEditor()
host = document.createElement('div')
host.className = 'editor-host'
// Docked inside #content: below the banner, next to the article only.
const container = (!standalone && document.getElementById('content')) || document.body
container.prepend(host)
if (!standalone) {
document.body.classList.add('editing')
// Which kind of editor is open; pagerite.js uses this to decide
// whether a pen click closes the panel or swaps in the other editor.
document.body.dataset.editorMode = mode
}
document.getElementById('content').prepend(host)
document.body.classList.add('editing')
// Which kind of editor is open; pagerite.js uses this to decide
// whether a pen click closes the panel or swaps in the other editor.
document.body.dataset.editorMode = mode
createApp(mode === 'site' ? SiteEditor : PageEditor, {
pagePath: path,
standalone,
onClose: closeEditor,
}).mount(host)
}
@@ -47,14 +41,3 @@ export function closeEditor() {
host = null
setTimeout(() => old.remove(), 250)
}
const shell = document.getElementById('app')
if (shell) {
// Standalone /_admin shell: mount into it and follow the location hash.
host = shell
createApp(PageEditor, {
pagePath: location.hash.replace(/^#\/?/, '').replace(/\/$/, ''),
standalone: true,
onClose: () => {},
}).mount(shell)
}
+1 -1
View File
@@ -132,7 +132,7 @@
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);
urls.add(a.pathname);
}
for (const url of urls) {
if (url === location.pathname) continue;
+2 -3
View File
@@ -9,8 +9,7 @@ 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.
// backend's /_ prefix. /_api and /_f are handled by the fastapi-vue plugin.
const CONTENT_PROXY = '^\\/(?!_|@|src|node_modules|__)(?:[^./?]+(?:\\/[^./?]+)*)?(?:\\?.*)?$'
// https://vite.dev/config/
@@ -22,10 +21,10 @@ export default defineConfig({
],
server: {
proxy: {
"/_admin": { target: backendUrl, changeOrigin: false },
[CONTENT_PROXY]: { target: backendUrl, changeOrigin: false },
},
},
appType: 'mpa', // no SPA fallback; every HTML page is served by FastAPI
build: {
// Mirror the URL space in the build output: hashed files land under
// frontend-build/_assets/ and the Frontend serves the build directory
-6
View File
@@ -549,12 +549,6 @@ async def editor_ws(ws: WebSocket) -> None:
pass
@app.get("/_admin", response_class=HTMLResponse)
async def admin() -> HTMLResponse:
"""Serve the editor app shell (Vue mounts into #app)."""
return HTMLResponse(views.render_editor())
@app.get("/")
async def front_page(request: Request) -> Response:
"""Render the front page (slug path "")."""
+8 -38
View File
@@ -72,15 +72,6 @@ def _editor_css_url(vite_url: str | None) -> str | None:
return None
def _editor_page_css_urls(vite_url: str | None) -> list[str]:
"""All CSS URLs for the standalone admin editor page."""
if vite_url:
return []
shared = _shared_css_urls(None)
editor_css = _editor_css_url(None)
return [*shared, editor_css] if editor_css else shared
def _layout(urls: list[str], modules: list[str] = ()) -> Template:
"""Page layout template with standard asset URLs and ES-module scripts.
@@ -230,12 +221,12 @@ def _edit_attrs(path: str, mode: str = "page") -> dict:
(the pen on the banner) edits the banner and site structure. They are
buttons, not links: editing is an action, not a navigation.
"""
scripts, _styles, editor_css = _editor_assets()
script, editor_css = _editor_assets()
return {
"type": "button",
"class": "edit-link" if mode == "page" else "edit-link banner-edit-link",
"title": "edit",
"data-editor-src": scripts[-1],
"data-editor-src": script[-1],
"data-editor-css": editor_css or "",
"data-editor-mode": mode,
}
@@ -346,38 +337,17 @@ def _page_assets() -> tuple[list[str], list[str]]:
return _asset_cache["page"]
def _editor_assets() -> tuple[list[str], list[str], str | None]:
"""Script URLs, page CSS URLs, and editor-specific CSS URL.
def _editor_assets() -> tuple[list[str], str | None]:
"""Script URL and editor-specific CSS URL for the public-page edit pen.
The standalone admin page uses all CSS URLs; the public-page edit pen
only needs the editor-specific URL because the shared CSS is already
linked on the page.
The shared CSS is already linked on the page, so the pen only needs the
editor-specific stylesheet.
"""
vite_url = os.environ.get("PAGERITE_VITE_URL")
if vite_url:
return (
[f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"],
_shared_css_urls(vite_url),
None,
)
return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None
if "editor" not in _asset_cache:
manifest = _manifest()
entry = manifest["src/main.js"]
_asset_cache["editor"] = (
[f"/{entry['file']}"],
_editor_page_css_urls(None),
_editor_css_url(None),
)
_asset_cache["editor"] = [f"/{entry['file']}"], _editor_css_url(None)
return _asset_cache["editor"]
def render_editor() -> str:
"""Render the admin editor shell: a mount point for the Vue app."""
scripts, styles, _editor_css = _editor_assets()
doc = Document(f"Admin {SITE_NAME}", lang="en")
for url in styles:
doc.link(rel="stylesheet", href=url, blocking="render")
for src in scripts:
doc.script(src=src, type="module")
doc.div(None, id="app")
return str(doc)