aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md26
-rw-r--r--README.md5
-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
16 files changed, 219 insertions, 63 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 84b6a7b..d4a9284 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -185,13 +185,23 @@ internal/web/ server.go (host router, middleware, render helpers)
header text colour; hidden on phones so a long title can wrap. `NavStyle`
`plain` is regular weight + underline for the menu links (blog.css makes
them bold).
+ **Custom fonts** (`CustomFont`, `CustomHeadingFont`: font file ids or ""):
+ a chosen file is declared as `@font-face` (`"BlogFont"` / `"BlogHeadingFont"`,
+ `src:url(/media/<id>)`, same-origin so no CORS) and put first in the stack,
+ the built-in `Font`/`HeadingFont` choice behind it as the fallback — so
+ there is no "custom" enum value and no invalid state. `HeadingFontFamily`
+ builds on the text's stack when `HeadingFont` is `same`, and the `h1…h6`
+ rule is emitted whenever `HeadingFontSet` (a heading choice or a heading
+ file). Picked with the `fontpick` partial: a plain `<select>` of the newest
+ `recentImages` library fonts (`kind = font`) plus the chosen older one, and
+ an upload input — no library panel, no script.
Optional colours (`LinkHover`, `NavHover`) are `""` = inherit and come
from a colour input paired with a `<name>_custom` checkbox, shown/hidden
with CSS only (`.hoverpick > input:not(:checked) ~ …`). Image fields use the
`imagepick` partial: a `<select>` of the `recentImages` newest library
images (`kind = image`) plus the chosen one (`imageNames` looks up an older
- one's filename), so the page never grows with the library; `none` clears, a
- uuid selects, no value keeps (`pickImage`); an upload in `<name>_file` wins.
+ one's filename, fonts included), so the page never grows with the library; `none` clears, a
+ uuid selects, no value keeps (`pickFile`); an upload in `<name>_file` wins.
The `imagelib` partial (one per form; the design page and the post form
both include it) is the panel behind "Choose from library…", with its own
script: it marks every `.imagepick` with `.js` (hiding the select, showing
@@ -245,15 +255,18 @@ internal/web/ server.go (host router, middleware, render helpers)
- **Files** (`files` table, `store/files.go`, `handlers_files.go`,
`filetype.go`, `/b/{sub}/files…`): the upload library, any type. A row is
`id, filename, content_type, kind, size, data`; `kind` (image, document,
- audio, video, archive, other) is decided at upload by `fileType`, which
+ audio, video, font, archive, other) is decided at upload by `fileType`, which
trusts `http.DetectContentType` first and lets the extension refine only a
generic sniff (text/plain, octet-stream) to a type on the allowlists in
`filetype.go`; anything unknown is stored as `application/octet-stream`.
+ Fonts (`fontTypes`: WOFF2, WOFF, TTF, OTF) are known by their bytes alone,
+ like images; TTC and EOT stay `other`.
`/media/{uuid}` is served on every host with immutable cache headers, from
the host's blog database only (the root domain serves the root blog's
files); dashboard previews for another blog on the root host use
`GET /b/{sub}/media/{id}` (`withBlog`). **The root domain carries the
- session cookie, so `servedAs` renders inline only images, PDF, plain text,
+ session cookie, so `servedAs` renders inline only images, fonts (a browser
+ parses one only inside `@font-face`), PDF, plain text,
audio and video; everything else (HTML, SVG, XML, JS, archives, binaries)
goes out as `application/octet-stream` + `Content-Disposition: attachment`.**
`?download` forces attachment. SVG is therefore never an image (download
@@ -293,7 +306,8 @@ internal/web/ server.go (host router, middleware, render helpers)
`outro_file`) + `appendFile(body, f, format)`
(the no-JS path: `fileMarkdown` or `fileHTML`, i.e. `<img>`/`<a>`), and
store `renderBody(format, src)` (`markdown.Render`, or the source untouched
- for HTML) in the `*_html` column — the public templates only ever print
+ for HTML) in the `*_html` column (`readUpload`'s last argument is the kind
+ the field must be — `"image"`, `"font"` — or `""` for anything) — the public templates only ever print
that column.
**HTML mode** (`.editor.html`, toggled by the radios): the Markdown toolbar
groups (`.ed-md`), link box, shortcuts, list continuation and cheat-sheet
@@ -444,7 +458,7 @@ superadmin password to `admin`. Production refuses both.
`-- +goose Up/Down` sections; they run automatically at startup. Never
edit an applied migration. Blog chain so far: `00001_init`, `00002_language`,
`00003_files`, `00004_tags`, `00005_search`, `00006_format`, `00007_post_image`,
- `00008_page_outro`, `00009_gizmo_style`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after
+ `00008_page_outro`, `00009_gizmo_style`, `00010_font_kind`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after
the move to per-blog databases; deployments from before it have
`goose_db_version` rows 2–7 in the control DB that must be deleted once
(README "Upgrading from a single database") or the next control migration
diff --git a/README.md b/README.md
index 295fe87..b6adffe 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones.
keyboard shortcuts and a rendered preview — or, with the editor's Format switch, in
**raw HTML** that goes on the blog exactly as written (embeds, scripts, inline styles;
the blogger's own responsibility, like the custom HTML module). Files of any
- kind — images, PDFs, archives, audio… — can be inserted straight from the editor (file
+ kind — images, PDFs, archives, audio, fonts… — can be inserted straight from the editor (file
picker, paste or drag-and-drop; with JavaScript off the file is appended on save):
images are shown, everything else becomes a download link. The **Files** tab lists
them by kind with search, rename and delete. Uploads are 10 MB per file by default;
@@ -23,7 +23,8 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones.
with it. A post can have a **featured image**: a thumbnail beside it on listings,
and the picture above or below the text on its own page.
- **Design** tab: one form, one Save, organised by part of the blog — colours & fonts
- (schemes, background colour/image, text and link colours, fonts), header, menu,
+ (schemes, background colour/image, text and link colours, fonts — built-in
+ stacks or your own uploaded WOFF2/WOFF/TTF/OTF files), header, menu,
content, side columns, footer, logo & site icon. The page is five areas — header,
left column, main content, right column, footer — each holding modules in any
order: blog title, logo, menu, archive (posts by year and month), recent posts,
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