diff options
| -rw-r--r-- | AGENTS.md | 26 | ||||
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | internal/db/migrations/blog/00010_font_kind.sql | 21 | ||||
| -rw-r--r-- | internal/i18n/el.go | 8 | ||||
| -rw-r--r-- | internal/web/filetype.go | 19 | ||||
| -rw-r--r-- | internal/web/filetype_test.go | 11 | ||||
| -rw-r--r-- | internal/web/handlers_design.go | 28 | ||||
| -rw-r--r-- | internal/web/handlers_files.go | 23 | ||||
| -rw-r--r-- | internal/web/handlers_pages.go | 4 | ||||
| -rw-r--r-- | internal/web/handlers_posts.go | 6 | ||||
| -rw-r--r-- | internal/web/handlers_sections.go | 2 | ||||
| -rw-r--r-- | internal/web/static/dashboard.css | 5 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/design.html | 8 | ||||
| -rw-r--r-- | internal/web/templates/partials/fontpick.html | 13 | ||||
| -rw-r--r-- | internal/web/theme.go | 68 | ||||
| -rw-r--r-- | internal/web/web_test.go | 35 |
16 files changed, 219 insertions, 63 deletions
@@ -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 @@ -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 |
