feat(branding): self-hosted webfonts with filesystem scanner
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
# Self-hosted webfonts
|
||||
|
||||
PicPeak ships with a curated set of webfonts baked into the backend image and serves them from your own origin. **No requests go to `fonts.googleapis.com` or any third-party CDN** — guest IPs stay private, which is important for GDPR compliance (LG München 2022).
|
||||
|
||||
The font picker in the admin theme customizer is **data-driven**: whatever the backend finds on disk, the picker offers. This page documents the conventions and the workflow for adding your own families.
|
||||
|
||||
## What ships out of the box
|
||||
|
||||
The Docker image bundles 8 OFL-licensed families at `backend/assets/fonts/`:
|
||||
|
||||
- Comic Neue
|
||||
- IBM Plex Sans
|
||||
- Inter (the default)
|
||||
- Jost
|
||||
- Montserrat
|
||||
- Noto Sans
|
||||
- Playfair Display
|
||||
- Poppins
|
||||
|
||||
These appear in the admin theme customizer with no configuration.
|
||||
|
||||
## Adding your own font (drop a folder, restart)
|
||||
|
||||
You don't need to fork the repo. Place a font folder in your runtime storage volume — the same volume that holds events, thumbnails, etc. — and it appears in the picker after the next backend restart (or within ~30 seconds of being added, whichever comes first).
|
||||
|
||||
### 1. Choose where on the host
|
||||
|
||||
Bind-mount target inside the container is `/app/storage/fonts/` (the env var `STORAGE_PATH` controls the prefix; defaults to `/app/storage`). On the host, that's wherever your `docker-compose.yml` mounts `${APP_STORAGE}` from — typically `./storage/`.
|
||||
|
||||
### 2. Folder layout
|
||||
|
||||
```
|
||||
storage/fonts/
|
||||
└── <Family-Name>/
|
||||
├── 400.woff2
|
||||
├── 600.woff2
|
||||
└── 700.woff2
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Folder name** = display family name with spaces replaced by hyphens. The scanner turns `Roboto-Slab/` → `Roboto Slab`. Use the exact upstream family name; capitalisation is preserved.
|
||||
- **File names** are `<weight>.woff2` where `<weight>` is an integer (100-900). Other names are ignored. The picker doesn't expose individual weights, but the runtime injects all available weights in the `@font-face` block so headings (semibold/bold) render correctly.
|
||||
- **Format** must be `.woff2`. Other formats are ignored. WOFF2 is universally supported and the smallest on the wire.
|
||||
- **No italics** in v1 (the picker doesn't expose them). Italic files in the folder are silently ignored.
|
||||
|
||||
### 3. Where to download fonts
|
||||
|
||||
For Google-Fonts-licensed families, use [google-webfonts-helper](https://gwfh.mranftl.com/fonts):
|
||||
|
||||
1. Pick the family.
|
||||
2. Charsets section → **Latin** only (uncheck others unless you actually need them; Cyrillic alone roughly doubles file size).
|
||||
3. Styles section → **400, 600, 700** at minimum (these match what the picker uses).
|
||||
4. Click "Download files" — you'll get a ZIP containing the `.woff2` files plus the family's OFL license.
|
||||
5. Rename the files to `400.woff2`, `600.woff2`, `700.woff2` and drop them in `storage/fonts/<Family-Name>/`.
|
||||
6. Keep the OFL license file alongside (the static handler serves anything in the folder, so `/fonts/<Family-Name>/OFL.txt` is publicly available — this satisfies OFL §2's "license must be included with all copies").
|
||||
|
||||
For non-Google fonts, ensure you have the right to redistribute. SIL Open Font License (OFL), Apache 2.0, and most "free for commercial use" web licenses allow this.
|
||||
|
||||
### 4. Activation
|
||||
|
||||
Either:
|
||||
|
||||
- **Restart the backend container** (immediate), or
|
||||
- **Wait ~30 seconds** for the in-memory cache to expire and the next `/api/public/fonts` request to re-scan.
|
||||
|
||||
Refresh the admin customizer; the new family appears in the body and heading dropdowns.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Scanner**: `backend/src/services/fontsService.js` reads two locations and merges them: `backend/assets/fonts/` (bundled) + `STORAGE_PATH/fonts/` (user). User additions override bundled families of the same name. Cached for 30 s.
|
||||
- **Listing endpoint**: `GET /api/public/fonts` returns `{ fonts: [{ family, weights }, ...] }`.
|
||||
- **Static serving**: `GET /fonts/<Family-Name>/<weight>.woff2` returns the actual file. Path-traversal protected. `Cache-Control: max-age=7d, immutable`.
|
||||
- **Lazy injection**: `frontend/src/contexts/ThemeContext.tsx` watches `theme.fontFamily` / `theme.headingFontFamily` and injects exactly one `@font-face` block per family the page actually uses, into a single `<style id="self-hosted-fonts">` element. Other families are not loaded for that visitor.
|
||||
- **Bootstrap**: `frontend/src/index.css` ships static `@font-face` blocks for Inter so the very first paint already has the default body font.
|
||||
|
||||
## Caveats and edge cases
|
||||
|
||||
- **Empty folder** (no `<weight>.woff2` files) → silently skipped, warning in backend logs.
|
||||
- **Two folders that normalize to the same family** on case-insensitive filesystems (macOS APFS) → second is skipped, warning logged.
|
||||
- **Weight files with non-numeric names** (e.g. `bold.woff2`, `regular.woff2`) → ignored; family entry still includes its other weights.
|
||||
- **Removing the `Inter/` folder** → the very-first-paint bootstrap CSS in `index.css` will 404 the font requests; browsers fall back to the next family in `--font-family` (Noto Sans → system-ui). Cosmetic only; no other breakage.
|
||||
- **Variable fonts** are not supported in v1. Each weight must be a separate file.
|
||||
- **Italics** are not exposed in the picker.
|
||||
|
||||
## License
|
||||
|
||||
The bundled fonts are all SIL Open Font License v1.1 (OFL). The license text and per-font copyright notices live at `backend/assets/fonts/LICENSE-OFL.txt` and are publicly served at `/fonts/LICENSE-OFL.txt`.
|
||||
|
||||
If you redistribute the PicPeak Docker image, you redistribute these fonts too — you must keep the LICENSE-OFL.txt file accessible. The default static handler does this for you.
|
||||
Reference in New Issue
Block a user