Add favicon upload to the site editor

Data.favicon names a blob in the content-addressed files store (no
migration: msgspec default). PUT/DELETE /_api/settings/favicon upload and
clear it; when set, every page links it as <link rel="icon">, otherwise
browsers fall back to the build's /favicon.ico. SiteEditor shows a preview
with upload/replace/remove and applies the change to the live page head.
This commit is contained in:
2026-08-18 05:22:25 +00:00
parent d03ee7fa8f
commit c0817330e7
5 changed files with 170 additions and 9 deletions
+7 -1
View File
@@ -74,6 +74,11 @@ not for the public pages. See `docs/design-principles.md` for the design.
referencing the per-family variables (`--font-source-sans` etc.) from
pagerite.css;
the base stylesheet's `--font-brand` defaults to `var(--font-heading)`.
`Data.favicon` names a file in the content-addressed `files` store,
uploaded/cleared in the site editor via `PUT`/`DELETE
/_api/settings/favicon`; when set it is linked as `<link rel="icon">`
on every page, otherwise browsers fall back to the build's
`/favicon.ico` by convention.
- `markdown.py` — markdown-it-py renderer (html passthrough + attrs,
footnote, deflist, tasklists plugins). Custom image rule: relative srcs
resolve against the page path, titled images become figures.
@@ -141,7 +146,8 @@ not for the public pages. See `docs/design-principles.md` for the design.
previewing into the visible article; editor scroll drives document
scroll) opened by the article pen — it edits content and title only,
never the path — and `SiteEditor.vue` (site brand + theme selector +
site-wide custom CSS + banner HTML edited in small CodeMirror windows;
favicon upload/remove + site-wide custom CSS + banner HTML edited in
small CodeMirror windows;
banner previewed into `#page-banner`, CSS injected into
`<head id="pagerite-user">`) + vue-draggable structure tree with
always-editable title/slug inputs per row, opened by the banner pen —
+109
View File
@@ -315,9 +315,60 @@ async function loadSettings() {
brand.value = s.brand
theme.value = s.theme || ''
customCss.value = s.custom_css || ''
favicon.value = s.favicon || ''
} catch { /* keep default */ }
}
// --- Favicon ---------------------------------------------------------------
// Uploaded into the content-addressed file store (PUT /_api/settings/favicon)
// and linked on every page as <link rel="icon">; empty falls back to the
// build's /favicon.ico. Applies to the live page immediately.
const favicon = ref('')
const faviconInput = ref(null)
function applyFavicon(url) {
let link = document.querySelector('link[rel="icon"]')
if (url) {
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
link.id = 'pagerite-favicon'
document.head.append(link)
}
link.href = url
} else if (link?.id === 'pagerite-favicon') {
link.remove()
}
}
async function uploadFavicon(file) {
if (!file || !file.type.startsWith('image/')) return
const res = await fetch('/_api/settings/favicon', {
method: 'PUT',
headers: { 'x-filename': file.name.replace(/[^\w.-]/g, '-') },
body: file,
})
if (res.ok) {
saveError.value = ''
const { path: url } = await res.json()
favicon.value = url
applyFavicon(url)
} else {
saveError.value = `⚠️ ${await errorDetail(res)}`
}
}
async function removeFavicon() {
const res = await fetch('/_api/settings/favicon', { method: 'DELETE' })
if (res.ok) {
saveError.value = ''
favicon.value = ''
applyFavicon('')
} else {
saveError.value = `⚠️ ${await errorDetail(res)}`
}
}
function currentTitle() {
return flatMap.value[path.value]?.title
|| document.title.replace(/ [^]*$/, '')
@@ -887,6 +938,28 @@ onUnmounted(() => {
A
</button>
</label>
<div class="favicon-row">
<img v-if="favicon" :src="favicon" class="favicon-preview" alt="" />
<span v-else class="favicon-preview favicon-empty">?</span>
<button
type="button"
title="upload favicon (ico, png, svg...)"
@click="faviconInput.click()"
>{{ favicon ? 'replace favicon' : 'upload favicon' }}</button>
<button
v-if="favicon"
type="button"
title="remove favicon (back to the default)"
@click="removeFavicon"
>remove</button>
<input
ref="faviconInput"
type="file"
accept="image/*"
hidden
@change="(ev) => { uploadFavicon(ev.target.files[0]); ev.target.value = '' }"
/>
</div>
<div v-if="fontPicker" class="font-picker">
<div class="font-tabs">
<button
@@ -1025,6 +1098,42 @@ onUnmounted(() => {
text-overflow: ellipsis;
}
/* Favicon row: tiny preview (or placeholder tile) + upload/remove buttons. */
.favicon-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.favicon-preview {
width: 1.4rem;
height: 1.4rem;
object-fit: contain;
border-radius: 3px;
background: var(--bg);
border: 1px solid var(--line);
}
.favicon-empty {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 0.85rem;
}
.favicon-row button {
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.6rem;
background: var(--accent2);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.block-head button {
margin-left: auto;
font: inherit;
+40 -5
View File
@@ -274,8 +274,13 @@ async def update_structure(op: StructureOp) -> None:
@app.get("/_api/settings")
async def get_settings() -> dict[str, str]:
"""Site-wide settings (brand, theme and custom CSS)."""
return {"brand": data.brand, "theme": data.theme, "custom_css": data.custom_css}
"""Site-wide settings (brand, theme, custom CSS and favicon URL)."""
return {
"brand": data.brand,
"theme": data.theme,
"custom_css": data.custom_css,
"favicon": f"/_f/{data.favicon}" if data.favicon else "",
}
class SettingsIn(BaseModel):
@@ -296,6 +301,36 @@ async def put_settings(settings: SettingsIn) -> None:
data.version += 1
@app.put("/_api/settings/favicon")
async def put_favicon(request: Request) -> dict[str, str]:
"""Upload a favicon into the content-addressed store and activate it.
Raw image body (ico/png/svg...); the stored name is a blake3 hash
prefix + extension, and pages link it as <link rel="icon">. Returns
{"path": "/_f/..."}.
"""
body = await request.body()
if not body:
raise HTTPException(400, "empty file")
stored = _hash_name(body, request.headers.get("x-filename", "favicon.ico"))
with kanta.transaction("upload favicon"):
data.files[stored] = body
data.favicon = stored
data.version += 1
return {"path": f"/_f/{stored}"}
@app.delete("/_api/settings/favicon", status_code=204)
async def delete_favicon() -> None:
"""Clear the custom favicon (back to the build's /favicon.ico).
The blob stays in the content-addressed store; only the reference goes.
"""
with kanta.transaction("clear favicon"):
data.favicon = ""
data.version += 1
class ToggleTaskIn(BaseModel):
"""Payload for toggling one task-list checkbox."""
@@ -575,17 +610,17 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
return HTMLResponse(
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme),
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon),
headers={"etag": etag},
)
if node is not None and node.published and node.content is None:
# Category label without a landing page: placeholder with the pen
# to create it (404 — no page here, but the node is real).
return HTMLResponse(views.render_category(data.menu, path, data.brand, data.custom_css, data.theme), 404)
return HTMLResponse(views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon), 404)
if node is None and not path:
# No front page (no top-level node with slug ""): "/" opens the
# first item of the navigation instead.
for slug, item in sorted_nodes(data.menu):
if item.published:
return RedirectResponse(f"/{slug}")
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme), 404)
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon), 404)
+3
View File
@@ -86,6 +86,9 @@ class Data(msgspec.Struct):
#: Raw site-wide custom CSS, injected inline in every page <head>.
#: Trusted author content; not sanitized.
custom_css: str = ""
#: Favicon: name of a file in `files` (content-addressed), linked as
#: <link rel="icon"> on every page. Empty = the build's /favicon.ico.
favicon: str = ""
#: Legacy flat page store (pre-tree databases); migrated into `menu`
#: on startup, then cleared. Never written otherwise.
pages: dict[str, Page] = {}
+11 -3
View File
@@ -86,6 +86,7 @@ def _layout(
modules: list[str] = (),
custom_css: str = "",
theme: str = "",
favicon: str = "",
) -> Template:
"""Page layout template with standard asset URLs and ES-module scripts.
@@ -99,6 +100,10 @@ def _layout(
doc = Document(E.Title, lang="en")
if theme:
doc.meta(name="pagerite:theme", content=theme)
# A custom favicon (from the site editor) is linked explicitly; without
# one, browsers fall back to the build's /favicon.ico by convention.
if favicon:
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
# Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens
# itself once it has validated the session (pages render identically
# for everyone; editing is gated by the auth proxy in front of /_api).
@@ -294,13 +299,14 @@ def render_page(
brand: str = SITE_NAME,
custom_css: str = "",
theme: str = "",
favicon: str = "",
) -> str:
"""Render a full HTML page for the slug path."""
node = resolve(menu, path)[-1]
title = _title(path.rpartition("/")[2], node)
scripts, styles = _page_assets(theme)
return str(
_layout(styles, scripts, custom_css, theme)(
_layout(styles, scripts, custom_css, theme, favicon)(
Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand),
Nav=nav_html(menu, path),
@@ -317,6 +323,7 @@ def render_category(
brand: str = SITE_NAME,
custom_css: str = "",
theme: str = "",
favicon: str = "",
) -> str:
"""Render the placeholder for a content-less category label (404).
@@ -336,7 +343,7 @@ def render_category(
doc.p("This section has no page of its own yet.")
scripts, styles = _page_assets(theme)
return str(
_layout(styles, scripts, custom_css, theme)(
_layout(styles, scripts, custom_css, theme, favicon)(
Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand),
Nav=nav_html(menu, path),
@@ -353,6 +360,7 @@ def render_not_found(
brand: str = SITE_NAME,
custom_css: str = "",
theme: str = "",
favicon: str = "",
) -> str:
"""Render a 404 page within the normal layout."""
doc = E.article
@@ -361,7 +369,7 @@ def render_not_found(
doc.p(f"No article at /{path}. If there was before, it may have been deleted.")
scripts, styles = _page_assets(theme)
return str(
_layout(styles, scripts, custom_css, theme)(
_layout(styles, scripts, custom_css, theme, favicon)(
Title=f"Not Found {brand}" if brand else "Not Found",
Brand=_brand_link(brand),
Nav=nav_html(menu, path),