aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-17 21:29:44 +0300
committergrm <grm@eyesin.space>2026-09-17 21:29:44 +0300
commite96bbec551c0388e972894b349413fa6ef607352 (patch)
treec63b50d515fc0a0f754f970b8d316dd37f73ac80 /internal
parent69912c4ac04ada3286c09400ce523d5d3ab4cfb4 (diff)
downloadblogspace-e96bbec551c0388e972894b349413fa6ef607352.tar.gz
blogspace-e96bbec551c0388e972894b349413fa6ef607352.tar.bz2
blogspace-e96bbec551c0388e972894b349413fa6ef607352.zip
Add custom fonts: upload WOFF2/WOFF/TTF/OTF files and pick them on the Design tab
Fonts become a file kind of their own (sniffed from the bytes like images, served inline from /media), and the theme gets custom_font and custom_heading_font: a chosen file is declared as @font-face and put first in the stack, the built-in Font / Heading font choice behind it as the fallback, so there is no "custom" enum value and no invalid state. The pickers are plain selects with an upload input, no script. Fonts uploaded before this are reclassified by the migration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal')
-rw-r--r--internal/db/migrations/blog/00010_font_kind.sql21
-rw-r--r--internal/i18n/el.go8
-rw-r--r--internal/web/filetype.go19
-rw-r--r--internal/web/filetype_test.go11
-rw-r--r--internal/web/handlers_design.go28
-rw-r--r--internal/web/handlers_files.go23
-rw-r--r--internal/web/handlers_pages.go4
-rw-r--r--internal/web/handlers_posts.go6
-rw-r--r--internal/web/handlers_sections.go2
-rw-r--r--internal/web/static/dashboard.css5
-rw-r--r--internal/web/templates/dashboard/design.html8
-rw-r--r--internal/web/templates/partials/fontpick.html13
-rw-r--r--internal/web/theme.go68
-rw-r--r--internal/web/web_test.go35
14 files changed, 196 insertions, 55 deletions
diff --git a/internal/db/migrations/blog/00010_font_kind.sql b/internal/db/migrations/blog/00010_font_kind.sql
new file mode 100644
index 0000000..cf741e4
--- /dev/null
+++ b/internal/db/migrations/blog/00010_font_kind.sql
@@ -0,0 +1,21 @@
+-- +goose Up
+-- Fonts become a file kind of their own so the design page can offer them.
+ALTER TABLE files DROP CONSTRAINT files_kind_check;
+ALTER TABLE files ADD CONSTRAINT files_kind_check
+ CHECK (kind IN ('image', 'document', 'audio', 'video', 'font', 'archive', 'other'));
+-- Fonts uploaded before this were stored as "other"; the magic bytes tell them
+-- apart (the same signatures the Go sniffer trusts), and substring() reads
+-- only that slice.
+UPDATE files SET kind = 'font', content_type = CASE substring(data from 1 for 4)
+ WHEN '\x774f4632'::bytea THEN 'font/woff2'
+ WHEN '\x774f4646'::bytea THEN 'font/woff'
+ WHEN '\x4f54544f'::bytea THEN 'font/otf'
+ ELSE 'font/ttf' END
+WHERE kind = 'other'
+ AND substring(data from 1 for 4) IN ('\x774f4632'::bytea, '\x774f4646'::bytea, '\x4f54544f'::bytea, '\x00010000'::bytea);
+
+-- +goose Down
+UPDATE files SET kind = 'other', content_type = 'application/octet-stream' WHERE kind = 'font';
+ALTER TABLE files DROP CONSTRAINT files_kind_check;
+ALTER TABLE files ADD CONSTRAINT files_kind_check
+ CHECK (kind IN ('image', 'document', 'audio', 'video', 'archive', 'other'));
diff --git a/internal/i18n/el.go b/internal/i18n/el.go
index 911eaf0..852d9ab 100644
--- a/internal/i18n/el.go
+++ b/internal/i18n/el.go
@@ -194,6 +194,7 @@ var el = map[string]string{
"File is too large (max %s).": "Το αρχείο είναι πολύ μεγάλο (έως %s).",
"File is empty.": "Το αρχείο είναι κενό.",
"Only PNG, JPEG, GIF, WebP and ICO images can be used here.": "Εδώ μπορούν να χρησιμοποιηθούν μόνο εικόνες PNG, JPEG, GIF, WebP και ICO.",
+ "Only WOFF2, WOFF, TTF and OTF fonts can be used here.": "Εδώ μπορούν να χρησιμοποιηθούν μόνο γραμματοσειρές WOFF2, WOFF, TTF και OTF.",
"Upload limit": "Όριο μεταφόρτωσης",
"default": "προεπιλογή",
"change": "αλλαγή",
@@ -393,7 +394,7 @@ var el = map[string]string{
"Favicon": "Εικονίδιο (favicon)",
"The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon.": "Το μικρό εικονίδιο που δείχνουν οι φυλλομετρητές στην καρτέλα. Ένα τετράγωνο PNG ή ICO 32–64 px είναι ιδανικό· επιλέξτε Κανένα για το εικονίδιο του Blogspace.",
"Reset to defaults": "Επαναφορά προεπιλογών",
- "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library).": "Επαναφέρει κάθε χρώμα, γραμματοσειρά και επιλογή διάταξης όπως ξεκινά ένα νέο ιστολόγιο και ξεχνά τις επιλεγμένες εικόνες (μένουν στη συλλογή σας).",
+ "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images and fonts (they stay in your library).": "Επαναφέρει κάθε χρώμα, γραμματοσειρά και επιλογή διάταξης όπως ξεκινά ένα νέο ιστολόγιο και ξεχνά τις επιλεγμένες εικόνες και γραμματοσειρές (μένουν στη συλλογή σας).",
"Reset everything": "Επαναφορά όλων",
"None": "Κανένα",
"…or upload a new one": "…ή μεταφορτώστε καινούργια",
@@ -464,6 +465,11 @@ var el = map[string]string{
"Page background": "Φόντο σελίδας",
"Text & links": "Κείμενο & σύνδεσμοι",
"Fonts": "Γραμματοσειρές",
+ "Your own fonts": "Δικές σας γραμματοσειρές",
+ "A WOFF2, WOFF, TTF or OTF file from your library. It is used instead of the choice above, which readers see until it loads or if their browser cannot show it.": "Ένα αρχείο WOFF2, WOFF, TTF ή OTF από τη συλλογή σας. Χρησιμοποιείται αντί για την παραπάνω επιλογή, την οποία βλέπουν οι αναγνώστες μέχρι να φορτώσει ή αν ο φυλλομετρητής τους δεν μπορεί να τη δείξει.",
+ "Text font": "Γραμματοσειρά κειμένου",
+ "None: headings use the text font, or the heading font above.": "Κανένα: οι επικεφαλίδες χρησιμοποιούν τη γραμματοσειρά κειμένου ή την παραπάνω γραμματοσειρά επικεφαλίδων.",
+ "Current font": "Τρέχουσα γραμματοσειρά",
"A quick start: a scheme fills in every colour below. Images, fonts and layout are kept.": "Για γρήγορη αρχή: ένας συνδυασμός συμπληρώνει όλα τα παρακάτω χρώματα. Εικόνες, γραμματοσειρές και διάταξη διατηρούνται.",
"Apply a scheme when saving": "Εφαρμογή συνδυασμού κατά την αποθήκευση",
"Keep the colours below": "Διατήρηση των παρακάτω χρωμάτων",
diff --git a/internal/web/filetype.go b/internal/web/filetype.go
index 9c0f590..60a220e 100644
--- a/internal/web/filetype.go
+++ b/internal/web/filetype.go
@@ -17,7 +17,8 @@ import (
// SVG or XML page must never render there. The rules below make that a
// property of the stored content type: the sniffer is trusted first, a
// filename extension may only refine a generic sniff to a type on an
-// allowlist, and anything not on the inline list is a download.
+// allowlist, and anything not on the inline list is a download. Fonts are
+// on it: a browser only ever parses one inside @font-face, never as a page.
// maxUploadFiles is how many files one Files-page request may carry; the body
// cap of that route is this many upload limits.
@@ -27,10 +28,10 @@ const maxUploadFiles = 10
// offsets, and the whole upload sits in memory while it is stored.
const maxUploadMB = 1024
-var fileKinds = []string{"image", "document", "audio", "video", "archive", "other"}
+var fileKinds = []string{"image", "document", "audio", "video", "font", "archive", "other"}
// fileKindNames are the tab labels (translated where used, like moduleNames).
-var fileKindNames = map[string]string{"image": "Images", "document": "Documents", "audio": "Audio", "video": "Video", "archive": "Archives", "other": "Other"}
+var fileKindNames = map[string]string{"image": "Images", "document": "Documents", "audio": "Audio", "video": "Video", "font": "Fonts", "archive": "Archives", "other": "Other"}
var imageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true, "image/x-icon": true, "image/avif": true, "image/bmp": true}
@@ -50,6 +51,10 @@ var archiveTypes = map[string]bool{
"application/x-tar": true, "application/x-bzip2": true, "application/x-xz": true, "application/x-rar-compressed": true, "application/vnd.rar": true,
}
+// fontTypes are what the design page's font pickers take. Like images they
+// sniff exactly, so the name never refines into one; TTC and EOT stay "other".
+var fontTypes = map[string]bool{"font/woff2": true, "font/woff": true, "font/ttf": true, "font/otf": true}
+
var mediaTypes = map[string]bool{
"audio/mpeg": true, "audio/mp4": true, "audio/ogg": true, "audio/flac": true, "audio/wav": true, "audio/wave": true, "audio/x-wav": true, "audio/webm": true, "audio/aac": true,
"video/mp4": true, "video/ogg": true, "video/webm": true, "video/x-matroska": true, "video/quicktime": true,
@@ -91,8 +96,8 @@ func fileType(head []byte, filename string) (contentType, kind string) {
ct = "application/octet-stream"
}
}
- if !imageTypes[ct] && !extAllowed(ct) && !strings.HasPrefix(ct, "audio/") && !strings.HasPrefix(ct, "video/") {
- ct = "application/octet-stream" // sniffed HTML/XML, fonts, and everything else we do not name
+ if !imageTypes[ct] && !fontTypes[ct] && !extAllowed(ct) && !strings.HasPrefix(ct, "audio/") && !strings.HasPrefix(ct, "video/") {
+ ct = "application/octet-stream" // sniffed HTML/XML and everything else we do not name
}
return ct, kindOf(ct)
}
@@ -123,6 +128,8 @@ func kindOf(ct string) string {
return "audio"
case strings.HasPrefix(ct, "video/"):
return "video"
+ case fontTypes[ct]:
+ return "font"
case archiveTypes[ct]:
return "archive"
}
@@ -131,7 +138,7 @@ func kindOf(ct string) string {
// inlineOK says whether a browser may render the type in place.
func inlineOK(ct string) bool {
- return imageTypes[ct] || ct == "application/pdf" || ct == "text/plain" || strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "video/")
+ return imageTypes[ct] || fontTypes[ct] || ct == "application/pdf" || ct == "text/plain" || strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "video/")
}
// servedAs is the Content-Type and disposition /media answers with.
diff --git a/internal/web/filetype_test.go b/internal/web/filetype_test.go
index 1f1d89e..45c4166 100644
--- a/internal/web/filetype_test.go
+++ b/internal/web/filetype_test.go
@@ -35,8 +35,14 @@ func TestFileType(t *testing.T) {
{"hello world", "notes.txt", "text/plain", "document"},
{"hello world", "server.log", "text/plain", "document"}, // text/* names only confirm the sniff
{"hello world", "notes.md", "text/plain", "document"},
- {"hello world", "README", "text/plain", "document"}, // no extension: the bytes are all we have
- {"\x00\x01\x02\x03\xff\xfe", "font.ttf", "application/octet-stream", "other"},
+ {"hello world", "README", "text/plain", "document"}, // no extension: the bytes are all we have
+ {"\x00\x01\x02\x03\xff\xfe", "font.ttf", "application/octet-stream", "other"}, // a font is known by its bytes
+ {"wOF2\x00\x01\x00\x00", "body.woff2", "font/woff2", "font"},
+ {"wOFF\x00\x01\x00\x00", "body.woff", "font/woff", "font"},
+ {"\x00\x01\x00\x00\x00\x10", "body.ttf", "font/ttf", "font"},
+ {"OTTO\x00\x10\x00\x80", "body.otf", "font/otf", "font"},
+ {"OTTO\x00\x10\x00\x80", "evil.html", "font/otf", "font"},
+ {"ttcf\x00\x01\x00\x00", "family.ttc", "application/octet-stream", "other"},
{"hello world", "data.csv", "text/csv", "document"},
{"hello world", "evil.html", "application/octet-stream", "other"},
{"<svg xmlns='http://www.w3.org/2000/svg'><script>1</script></svg>", "evil.svg", "application/octet-stream", "other"},
@@ -69,6 +75,7 @@ func TestServedAs(t *testing.T) {
{"text/plain", false, "text/plain; charset=utf-8", "inline"},
{"audio/mpeg", false, "audio/mpeg", "inline"},
{"video/mp4", false, "video/mp4", "inline"},
+ {"font/woff2", false, "font/woff2", "inline"},
{"application/zip", false, "application/octet-stream", "attachment"},
{"text/html", false, "application/octet-stream", "attachment"},
{"image/svg+xml", false, "application/octet-stream", "attachment"},
diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go
index acc7569..792dcdc 100644
--- a/internal/web/handlers_design.go
+++ b/internal/web/handlers_design.go
@@ -13,7 +13,8 @@ import (
// the menu — saved in one go.
// recentImages is how many library images the pickers list up front; the
-// rest are fetched by the script's library panel, page by page.
+// rest are fetched by the script's library panel, page by page. The font
+// pickers list as many, with no panel behind them: a blog has a few fonts.
const recentImages = 48
// designView is the state the design page shows: what the database holds, or
@@ -71,13 +72,17 @@ func (s *Server) designData(r *http.Request, v designView) (map[string]any, erro
if err != nil {
return nil, err
}
- // The pickers list the recent images; a chosen one older than that still
+ fonts, _, err := bs.ListFiles(r.Context(), "font", "", recentImages, 0)
+ if err != nil {
+ return nil, err
+ }
+ // The pickers list the recent files; a chosen one older than that still
// needs its name for the select.
names := map[string]string{}
- for _, f := range images {
+ for _, f := range append(images, fonts...) {
names[f.ID.String()] = f.Filename
}
- for _, id := range []string{v.Theme.BgImage, v.Theme.HeaderImage, v.Theme.Logo, v.Theme.Favicon} {
+ for _, id := range []string{v.Theme.BgImage, v.Theme.HeaderImage, v.Theme.Logo, v.Theme.Favicon, v.Theme.CustomFont, v.Theme.CustomHeadingFont} {
if id == "" || names[id] != "" {
continue
}
@@ -115,7 +120,7 @@ func (s *Server) designData(r *http.Request, v designView) (map[string]any, erro
"templates": templates, "pageItem": store.MenuItem{PageID: &pageID}, "linkItem": store.MenuItem{},
"kinds": moduleKinds, "names": moduleNames, "areaNames": areaNames, "single": singleModuleAreas,
"counts": []int{3, 5, 8, 10, 15, 20}, "tagCounts": []int{5, 10, 20, 30, 50}, "perPage": postsPerPageChoices,
- "images": images, "imageNames": names, "imageCount": imageCount, "presets": Presets(), "today": time.Now(),
+ "images": images, "fonts": fonts, "imageNames": names, "imageCount": imageCount, "presets": Presets(), "today": time.Now(),
}, nil
}
@@ -164,16 +169,17 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
// Optional direct uploads from the pickers. One that is stored stays
// chosen in the form even when something else is rejected.
for _, u := range []struct {
- field string
- dst *string
- }{{"bg_image_file", &v.Theme.BgImage}, {"header_image_file", &v.Theme.HeaderImage}, {"logo_file", &v.Theme.Logo}, {"favicon_file", &v.Theme.Favicon}} {
- img, err := s.readUpload(r, u.field, true)
+ field, kind string
+ dst *string
+ }{{"bg_image_file", "image", &v.Theme.BgImage}, {"header_image_file", "image", &v.Theme.HeaderImage}, {"logo_file", "image", &v.Theme.Logo}, {"favicon_file", "image", &v.Theme.Favicon},
+ {"custom_font_file", "font", &v.Theme.CustomFont}, {"custom_heading_font_file", "font", &v.Theme.CustomHeadingFont}} {
+ f, err := s.readUpload(r, u.field, u.kind)
if err != nil {
note(err)
continue
}
- if img != nil {
- *u.dst = img.ID.String()
+ if f != nil {
+ *u.dst = f.ID.String()
}
}
if problem != nil {
diff --git a/internal/web/handlers_files.go b/internal/web/handlers_files.go
index 576f2ca..fd6ab69 100644
--- a/internal/web/handlers_files.go
+++ b/internal/web/handlers_files.go
@@ -20,9 +20,9 @@ import (
const filesPerPage = 50
// readUpload stores the file from a multipart field, returning nil if the
-// field is empty. imagesOnly is for the design page, whose fields become theme
-// image ids.
-func (s *Server) readUpload(r *http.Request, field string, imagesOnly bool) (*store.File, error) {
+// field is empty. kind restricts what is accepted ("" = anything): the pickers'
+// fields become theme image or font ids.
+func (s *Server) readUpload(r *http.Request, field, kind string) (*store.File, error) {
if r.MultipartForm == nil {
return nil, nil
}
@@ -30,10 +30,10 @@ func (s *Server) readUpload(r *http.Request, field string, imagesOnly bool) (*st
if len(fhs) == 0 {
return nil, nil
}
- return s.storeUpload(r, fhs[0], imagesOnly)
+ return s.storeUpload(r, fhs[0], kind)
}
-func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader, imagesOnly bool) (*store.File, error) {
+func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader, kind string) (*store.File, error) {
limit := currentBlog(r).UploadLimit(s.cfg)
tooBig := errors.New(s.trf(r, "File is too large (max %s).", humanSize(limit)))
if fh.Size > limit {
@@ -55,11 +55,14 @@ func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader, imagesOn
return nil, errors.New(s.tr(r, "File is empty."))
}
name := cleanFilename(fh.Filename)
- ct, kind := fileType(buf.Bytes(), name)
- if imagesOnly && kind != "image" {
+ ct, got := fileType(buf.Bytes(), name)
+ switch {
+ case kind == "image" && got != kind:
return nil, errors.New(s.tr(r, "Only PNG, JPEG, GIF, WebP and ICO images can be used here."))
+ case kind == "font" && got != kind:
+ return nil, errors.New(s.tr(r, "Only WOFF2, WOFF, TTF and OTF fonts can be used here."))
}
- return blogStore(r).CreateFile(r.Context(), name, ct, kind, buf.Bytes())
+ return blogStore(r).CreateFile(r.Context(), name, ct, got, buf.Bytes())
}
// fileMarkdown is the line the editor inserts: an image for images, a link
@@ -204,7 +207,7 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
if msg != "" {
break
}
- f, err := s.storeUpload(r, fh, false)
+ f, err := s.storeUpload(r, fh, "")
if err != nil {
msg = err.Error()
break
@@ -284,7 +287,7 @@ func (s *Server) handleFileDelete(w http.ResponseWriter, r *http.Request) {
// Drop dangling references from the theme.
theme := ParseTheme(blog.ThemeJSON)
changed := false
- for _, ref := range []*string{&theme.BgImage, &theme.HeaderImage, &theme.Favicon, &theme.Logo} {
+ for _, ref := range []*string{&theme.BgImage, &theme.HeaderImage, &theme.Favicon, &theme.Logo, &theme.CustomFont, &theme.CustomHeadingFont} {
if *ref == id.String() {
*ref, changed = "", true
}
diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go
index 40456f2..8dc0f5f 100644
--- a/internal/web/handlers_pages.go
+++ b/internal/web/handlers_pages.go
@@ -66,8 +66,8 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
if autoSlug {
p.Slug = slug.Make(p.Title)
}
- img, err := s.readUpload(r, "intro_file", false)
- outroImg, err2 := s.readUpload(r, "outro_file", false)
+ img, err := s.readUpload(r, "intro_file", "")
+ outroImg, err2 := s.readUpload(r, "outro_file", "")
if err == nil {
err = err2
}
diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go
index ec56138..ac0dd90 100644
--- a/internal/web/handlers_posts.go
+++ b/internal/web/handlers_posts.go
@@ -114,7 +114,7 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
p.Format = pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML)
p.Published = r.FormValue("published") == "on"
p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64)
- p.Image = imageID(pickImage(p.Image, r.FormValue("image")))
+ p.Image = fileID(pickFile(p.Image, r.FormValue("image")))
tags, tagsOK := parseTags(strings.Split(r.FormValue("tags"), ","))
p.Tags = tags
autoSlug := p.Slug == ""
@@ -138,13 +138,13 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
return
}
}
- img, err := s.readUpload(r, "body_file", false)
+ img, err := s.readUpload(r, "body_file", "")
if err != nil {
fail(http.StatusBadRequest, s.tr(r, "File not added:")+" "+err.Error())
return
}
p.BodyMD = appendFile(p.BodyMD, img, p.Format)
- if f, err := s.readUpload(r, "image_file", true); err != nil { // an upload beats the picker
+ if f, err := s.readUpload(r, "image_file", "image"); err != nil { // an upload beats the picker
fail(http.StatusBadRequest, s.tr(r, "Image not added:")+" "+err.Error())
return
} else if f != nil {
diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go
index 04ba764..6e802d5 100644
--- a/internal/web/handlers_sections.go
+++ b/internal/web/handlers_sections.go
@@ -73,7 +73,7 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) {
fail := func(msg string) {
s.renderStatus(w, r, http.StatusBadRequest, "dashboard/section_form.html", map[string]any{"section": sec, "error": msg})
}
- img, err := s.readUpload(r, "body_file", false)
+ img, err := s.readUpload(r, "body_file", "")
switch {
case err != nil:
fail(s.tr(r, "File not added:") + " " + err.Error())
diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css
index f5e0fd4..4aa0213 100644
--- a/internal/web/static/dashboard.css
+++ b/internal/web/static/dashboard.css
@@ -269,6 +269,11 @@ textarea.mono { font-size: 0.9em; }
.pickrow .pickctl select { max-width: 24em; margin: 0 0 0.5em; }
.pickrow .pickctl .upload { margin: 0.5em 0 0; font-size: 0.85em; }
.pickrow .pickctl .upload input { max-width: 24em; }
+/* the design page's font pickers: a select over its upload input, two to a row */
+.fontpick { flex: 1 1 16em; min-width: 12em; }
+.fontpick select { max-width: 24em; margin: 0 0 0.5em; }
+.fontpick .upload { margin: 0 0 0.5em; font-size: 0.85em; }
+.fontpick .upload input { max-width: 24em; }
.picker { position: fixed; inset: 0; z-index: 20; background: rgba(16, 24, 40, 0.45); padding: 3vh 1em; overflow: auto; }
.picker-box { max-width: 860px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 8px 30px rgba(16, 24, 40, 0.25); padding: 1em 1.2em; }
.picker-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6em; margin-bottom: 0.8em; }
diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html
index 5fd3fbe..5c79dbb 100644
--- a/internal/web/templates/dashboard/design.html
+++ b/internal/web/templates/dashboard/design.html
@@ -84,6 +84,12 @@
<option value="large"{{if eq $t.FontSize "large"}} selected{{end}}>{{t "Large"}}</option>
</select></label>
</div>
+ <h3>{{t "Your own fonts"}}</h3>
+ <p class="muted small">{{t "A WOFF2, WOFF, TTF or OTF file from your library. It is used instead of the choice above, which readers see until it loads or if their browser cannot show it."}}</p>
+ <div class="row">
+ {{template "fontpick" (dict "name" "custom_font" "current" $t.CustomFont "fonts" .Data.fonts "names" .Data.imageNames "label" (t "Text font"))}}
+ {{template "fontpick" (dict "name" "custom_heading_font" "current" $t.CustomHeadingFont "fonts" .Data.fonts "names" .Data.imageNames "label" (t "Heading font") "hint" (t "None: headings use the text font, or the heading font above."))}}
+ </div>
</div>
<div class="card" id="header">
@@ -273,7 +279,7 @@
<details class="help reset">
<summary>{{t "Reset to defaults"}}</summary>
- <p>{{t "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library)."}}</p>
+ <p>{{t "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images and fonts (they stay in your library)."}}</p>
<form method="post" action="/b/{{$sub}}/design/reset">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<button type="submit" class="danger">{{t "Reset everything"}}</button>
diff --git a/internal/web/templates/partials/fontpick.html b/internal/web/templates/partials/fontpick.html
new file mode 100644
index 0000000..35820a2
--- /dev/null
+++ b/internal/web/templates/partials/fontpick.html
@@ -0,0 +1,13 @@
+{{/* One font field of the design page: a select of the recent library fonts
+ (plus a chosen one older than those, named through .names) and an upload
+ input. No library panel behind it — a blog has a handful of fonts — and
+ no script: the select is what the form sends (see pickFile). */}}
+{{define "fontpick"}}{{$cur := .current}}{{$listed := false}}{{range .fonts}}{{if eq .ID.String $cur}}{{$listed = true}}{{end}}{{end}}<div class="fontpick">
+ <label>{{.label}}<br><select name="{{.name}}">
+ <option value="none"{{if not $cur}} selected{{end}}>{{t "None"}}</option>
+ {{range .fonts}}<option value="{{.ID}}"{{if eq .ID.String $cur}} selected{{end}}>{{.Filename}}</option>{{end}}
+ {{if and $cur (not $listed)}}<option value="{{$cur}}" selected>{{with index .names $cur}}{{.}}{{else}}{{t "Current font"}}{{end}}</option>{{end}}
+ </select></label>
+ <label class="upload">{{t "…or upload a new one"}}<br><input type="file" name="{{.name}}_file" accept=".woff2,.woff,.ttf,.otf,font/woff2,font/woff,font/ttf,font/otf"></label>
+ {{if .hint}}<p class="muted small">{{.hint}}</p>{{end}}
+</div>{{end}}
diff --git a/internal/web/theme.go b/internal/web/theme.go
index c0c0729..26181c2 100644
--- a/internal/web/theme.go
+++ b/internal/web/theme.go
@@ -28,6 +28,11 @@ type Theme struct {
Font string `json:"font"` // sans | serif | mono
HeadingFont string `json:"heading_font"` // same | sans | serif | mono
FontSize string `json:"font_size"` // small | normal | large
+ // Uploaded fonts (file uuids or ""): when set, the blog is typeset in the
+ // file and Font/HeadingFont are the stack readers see until it loads, or
+ // instead of it when their browser cannot use it.
+ CustomFont string `json:"custom_font"`
+ CustomHeadingFont string `json:"custom_heading_font"`
HeaderImage string `json:"header_image"`
HeaderImageHeight string `json:"header_image_height"` // short | medium | tall
@@ -142,7 +147,7 @@ func color(v, def string) string {
// optColor is for colours that may be unset ("" = inherit).
func optColor(v string) string { return color(v, "") }
-func imageID(v string) string {
+func fileID(v string) string {
if _, err := uuid.Parse(v); err != nil {
return ""
}
@@ -153,7 +158,7 @@ func imageID(v string) string {
func (t *Theme) normalize() {
d := DefaultTheme()
t.BgColor = color(t.BgColor, d.BgColor)
- t.BgImage = imageID(t.BgImage)
+ t.BgImage = fileID(t.BgImage)
t.BgMode = pick(t.BgMode, "cover", "tile", "fixed")
t.ContentBg = color(t.ContentBg, d.ContentBg)
t.TextColor = color(t.TextColor, d.TextColor)
@@ -165,7 +170,9 @@ func (t *Theme) normalize() {
t.Font = pick(t.Font, "sans", "serif", "mono")
t.HeadingFont = pick(t.HeadingFont, "same", "sans", "serif", "mono")
t.FontSize = pick(t.FontSize, "normal", "small", "large")
- t.HeaderImage = imageID(t.HeaderImage)
+ t.CustomFont = fileID(t.CustomFont)
+ t.CustomHeadingFont = fileID(t.CustomHeadingFont)
+ t.HeaderImage = fileID(t.HeaderImage)
t.HeaderImageHeight = pick(t.HeaderImageHeight, "medium", "short", "tall")
t.HeaderAlign = pick(t.HeaderAlign, "left", "center")
t.HeaderBg = color(t.HeaderBg, d.HeaderBg)
@@ -184,9 +191,9 @@ func (t *Theme) normalize() {
t.FooterBg = color(t.FooterBg, d.FooterBg)
t.FooterColor = color(t.FooterColor, d.FooterColor)
t.FooterAlign = pick(t.FooterAlign, "center", "left")
- t.Logo = imageID(t.Logo)
+ t.Logo = fileID(t.Logo)
t.LogoSize = pick(t.LogoSize, "medium", "small", "large")
- t.Favicon = imageID(t.Favicon)
+ t.Favicon = fileID(t.Favicon)
t.LeftWidth = clampInt(t.LeftWidth, minColumnWidth, maxColumnWidth)
t.RightWidth = clampInt(t.RightWidth, minColumnWidth, maxColumnWidth)
if t.LeftWidth+t.RightWidth > maxColumnsSum { // the right column gives way
@@ -250,10 +257,12 @@ func ThemeFromForm(cur Theme, f url.Values) Theme {
t.FooterColor = get("footer_color")
t.FooterAlign = get("footer_align")
t.LogoSize = get("logo_size")
- t.BgImage = pickImage(t.BgImage, get("bg_image"))
- t.HeaderImage = pickImage(t.HeaderImage, get("header_image"))
- t.Favicon = pickImage(t.Favicon, get("favicon"))
- t.Logo = pickImage(t.Logo, get("logo"))
+ t.BgImage = pickFile(t.BgImage, get("bg_image"))
+ t.HeaderImage = pickFile(t.HeaderImage, get("header_image"))
+ t.Favicon = pickFile(t.Favicon, get("favicon"))
+ t.Logo = pickFile(t.Logo, get("logo"))
+ t.CustomFont = pickFile(t.CustomFont, get("custom_font"))
+ t.CustomHeadingFont = pickFile(t.CustomHeadingFont, get("custom_heading_font"))
t.HeaderOn, t.LeftOn, t.RightOn, t.FooterOn = on("header_on"), on("left_on"), on("right_on"), on("footer_on")
t.LeftWidth, t.RightWidth = num("left_width"), num("right_width")
t.KeepColumns = on("keep_columns")
@@ -280,9 +289,9 @@ func (t Theme) AreaOn(area string) bool {
// MainWidth is what the main column gets when both side columns are shown.
func (t Theme) MainWidth() int { return 100 - t.LeftWidth - t.RightWidth }
-// pickImage applies an imagepick radio: "none" clears, an id selects, and an
-// empty value (no radio in the form) keeps what was there.
-func pickImage(cur, v string) string {
+// pickFile applies an imagepick or fontpick select: "none" clears, an id
+// selects, and an empty value (no select in the form) keeps what was there.
+func pickFile(cur, v string) string {
switch v {
case "":
return cur
@@ -364,10 +373,31 @@ func fontStack(name string) string {
return `"Helvetica Neue", Helvetica, Arial, sans-serif`
}
-// Helpers used by the CSS template.
-func (t Theme) FontFamily() string { return fontStack(t.Font) }
+// Helpers used by the CSS template. The @font-face families are named for
+// their role, so a stack is the uploaded font first and the built-in choice as
+// its fallback.
+func (t Theme) FontFamily() string {
+ if t.CustomFont != "" {
+ return `"BlogFont", ` + fontStack(t.Font)
+ }
+ return fontStack(t.Font)
+}
+
+// HeadingFontFamily builds on the text's stack when the heading font is
+// "same", so a custom heading font falls back to the custom text font.
+func (t Theme) HeadingFontFamily() string {
+ stack := t.FontFamily()
+ if t.HeadingFont != "same" {
+ stack = fontStack(t.HeadingFont)
+ }
+ if t.CustomHeadingFont != "" {
+ return `"BlogHeadingFont", ` + stack
+ }
+ return stack
+}
-func (t Theme) HeadingFontFamily() string { return fontStack(t.HeadingFont) }
+// HeadingFontSet says whether headings need a font-family rule of their own.
+func (t Theme) HeadingFontSet() bool { return t.HeadingFont != "same" || t.CustomHeadingFont != "" }
func (t Theme) FontSizePx() string {
switch t.FontSize {
@@ -436,13 +466,19 @@ func (t Theme) HeaderImageMaxHeight() string {
}
var themeCSS = template.Must(template.New("theme").Funcs(template.FuncMap{"sub": func(a, b int) int { return a - b }}).Parse(`
+{{- if .CustomFont}}
+@font-face { font-family:"BlogFont"; src:url(/media/{{.CustomFont}}); font-display:swap; }
+{{- end}}
+{{- if .CustomHeadingFont}}
+@font-face { font-family:"BlogHeadingFont"; src:url(/media/{{.CustomHeadingFont}}); font-display:swap; }
+{{- end}}
body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.55;
{{- if .BgImage}} background-image:url(/media/{{.BgImage}});
{{- if eq .BgMode "cover"}} background-size:cover; background-position:center top; background-repeat:no-repeat;
{{- else if eq .BgMode "fixed"}} background-size:cover; background-position:center; background-attachment:fixed; background-repeat:no-repeat;
{{- else}} background-repeat:repeat;{{end}}
{{- end}} }
-{{- if ne .HeadingFont "same"}}
+{{- if .HeadingFontSet}}
h1, h2, h3, h4, h5, h6 { font-family:{{.HeadingFontFamily}}; }
{{- end}}
a { color:{{.LinkColor}};{{if eq .LinkUnderline "always"}} text-decoration:underline;{{else}} text-decoration:none;{{end}} }
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 914c991..42d29e4 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -82,8 +82,8 @@ func TestRootRoutePrecedence(t *testing.T) {
}
func TestThemeNormalizeAndCSS(t *testing.T) {
- th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","logo":"../etc","link_hover":"blue","heading_font":"wingdings","favicon":"x","logo_size":"huge","left_width":5,"right_width":90,"header_rule":"neon\"><script>"}`))
- if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.Logo != "" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" || th.LogoSize != "medium" || th.HeaderRule != "none" || th.RuleClass() != "" {
+ th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","logo":"../etc","link_hover":"blue","heading_font":"wingdings","favicon":"x","logo_size":"huge","left_width":5,"right_width":90,"header_rule":"neon\"><script>","custom_font":"x.woff2);}body{display:none"}`))
+ if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.Logo != "" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" || th.LogoSize != "medium" || th.HeaderRule != "none" || th.RuleClass() != "" || th.CustomFont != "" {
t.Errorf("normalize: %+v", th)
}
if th.LeftWidth != 15 || th.RightWidth != 40 {
@@ -128,6 +128,37 @@ func TestThemeNormalizeAndCSS(t *testing.T) {
if !strings.Contains(css, ".site-nav a { color:#222222; font-weight:normal; text-decoration:underline; }") {
t.Errorf("plain nav css: %s", css)
}
+ if css := DefaultTheme().CSS(); strings.Contains(css, "@font-face") || strings.Contains(css, "h1, h2") {
+ t.Errorf("default css should declare no font faces or heading rule: %s", css)
+ }
+}
+
+// An uploaded font is declared as a face and put first in the stack, the
+// built-in choice behind it; the heading font falls back to the text's stack.
+func TestCustomFontCSS(t *testing.T) {
+ body, head := "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "6ba7b811-9dad-11d1-80b4-00c04fd430c8"
+ th := ThemeFromForm(DefaultTheme(), url.Values{"custom_font": {body}, "font": {"serif"}})
+ css := th.CSS()
+ for _, want := range []string{`@font-face { font-family:"BlogFont"; src:url(/media/` + body + `); font-display:swap; }`, `font-family:"BlogFont", Georgia`} {
+ if !strings.Contains(css, want) {
+ t.Errorf("css missing %q:\n%s", want, css)
+ }
+ }
+ if strings.Contains(css, "BlogHeadingFont") || strings.Contains(css, "h1, h2") {
+ t.Errorf("no heading font was chosen: %s", css)
+ }
+ // A heading font with "same as text" still gets its own rule, falling back to the text font.
+ th = ThemeFromForm(th, url.Values{"custom_font": {body}, "custom_heading_font": {head}, "font": {"serif"}, "heading_font": {"same"}})
+ css = th.CSS()
+ for _, want := range []string{`font-family:"BlogHeadingFont"; src:url(/media/` + head + `)`, `h1, h2, h3, h4, h5, h6 { font-family:"BlogHeadingFont", "BlogFont", Georgia`} {
+ if !strings.Contains(css, want) {
+ t.Errorf("css missing %q:\n%s", want, css)
+ }
+ }
+ th = ThemeFromForm(th, url.Values{"custom_font": {"none"}, "custom_heading_font": {head}, "heading_font": {"mono"}})
+ if css = th.CSS(); strings.Contains(css, "BlogFont\"") || !strings.Contains(css, `h1, h2, h3, h4, h5, h6 { font-family:"BlogHeadingFont", "Courier New"`) {
+ t.Errorf("heading stack after clearing the text font: %s", css)
+ }
}
// The header rule is drawn beside the title and logo blocks — after them, and