feat self-host application fonts

Serve Work Sans and Courier Prime from the app so mobile rendering does not depend on late third-party font requests.

Commit
ecc46efc8ff1b0eaa2772244750878efee13e103
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/presenter/routes.ml
index 5f04fd99..e8e11eb8 100644..100644
@@ -38,3 +38,4 @@
38 38 let%path record_slot_edit = "/logbook/%s/slots/%d/edit"
39 39 let%path stylesheet = "/assets/hito.css"
40 40 let%path workout_client = "/assets/workout-client.js"
41 Added: let%path font = "/assets/fonts/%s"
lib/web/assets/fonts/courier-prime-400-latin.woff2
index 00000000..42dadfed 000000..100644

Binary files differ

lib/web/assets/fonts/courier-prime-700-latin.woff2
index 00000000..2d36acc0 000000..100644

Binary files differ

lib/web/assets/fonts/work-sans-latin-var.woff2
index 00000000..3a70b258 000000..100644

Binary files differ

lib/web/assets/hito.css
index 898c78d0..7f2a28b2 100644..100644
@@ -1,4 +1,30 @@
1 Removed: @import url("https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&family=Work+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,600&display=swap");
1 Added: /* Self-hosted fonts. The app embeds and serves these under /assets/fonts/, so
2 Added: the browser never reaches a third party. This removes the render-blocking
3 Added: Google Fonts @import that left Courier Prime absent on slow mobile networks.
4 Added: Work Sans ships as one variable WOFF2 covering the 400-800 weight axis.
5 Added: Courier Prime is static, so it needs one face per weight. Latin subset only;
6 Added: no italic face is used anywhere in the app. */
7 Added: @font-face {
8 Added: font-family: "Work Sans";
9 Added: font-style: normal;
10 Added: font-weight: 400 800;
11 Added: font-display: swap;
12 Added: src: url("/assets/fonts/work-sans-latin-var.woff2") format("woff2");
13 Added: }
14 Added: @font-face {
15 Added: font-family: "Courier Prime";
16 Added: font-style: normal;
17 Added: font-weight: 400;
18 Added: font-display: swap;
19 Added: src: url("/assets/fonts/courier-prime-400-latin.woff2") format("woff2");
20 Added: }
21 Added: @font-face {
22 Added: font-family: "Courier Prime";
23 Added: font-style: normal;
24 Added: font-weight: 700;
25 Added: font-display: swap;
26 Added: src: url("/assets/fonts/courier-prime-700-latin.woff2") format("woff2");
27 Added: }
2 28
3 29 :root {
4 30 color-scheme: light;
lib/web/handlers_assets.ml
index 931d1a48..b1d0f2aa 100644..100644
@@ -19,6 +19,24 @@
19 19 let asset_headers content_type =
20 20 [ ("Content-Type", content_type); ("Cache-Control", "no-cache") ]
21 21
22 Added: (* Fonts are the exception: each filename maps to fixed font bytes that never
23 Added: change, and they are the largest assets. A long immutable cache lets a
24 Added: returning visitor skip the download, which is the whole point of self-hosting
25 Added: them. A new font would ship under a new filename. *)
26 Added: let font_headers =
27 Added: [
28 Added: ("Content-Type", "font/woff2");
29 Added: ("Cache-Control", "public, max-age=31536000, immutable");
30 Added: ]
31 Added:
32 Added: (* Serve an embedded WOFF2 by name. The name is matched against the embedded
33 Added: set, so an unknown or crafted path yields a plain 404 rather than reaching
34 Added: the filesystem. *)
35 Added: let font name =
36 Added: match Stylesheet.read ("fonts/" ^ name) with
37 Added: | Some bytes -> Dream.respond ~headers:font_headers bytes
38 Added: | None -> Dream.respond ~status:`Not_Found ""
39 Added:
22 40 let routes =
23 41 [
24 42 Dream_html.get Routes.stylesheet (fun _ ->
@@ -29,4 +47,5 @@
29 47 Dream.respond
30 48 ~headers:(asset_headers "application/javascript; charset=utf-8")
31 49 workout_client);
50 Added: Dream_html.get Routes.font (fun _ name -> font name);
32 51 ]
test/web/workout.ml
index 6409c3df..ac11997e 100644..100644
@@ -35,6 +35,43 @@
35 35 let asset = get c "/assets/workout-client.js" in
36 36 Alcotest.(check int) "serves the workout client" 200 (status asset)
37 37 );
38 Added: ( "self-hosted fonts are served as woff2, and an unknown name is 404",
39 Added: `Quick,
40 Added: fun () ->
41 Added: (* The fonts are embedded and served from the app's own origin, so a
42 Added: visitor never reaches a third party. Each known face returns 200
43 Added: with the woff2 media type. An unknown or crafted name returns a
44 Added: plain 404 rather than reaching the filesystem. No sign-in is
45 Added: needed: fonts are public assets. *)
46 Added: let c = client () in
47 Added: let faces =
48 Added: [
49 Added: "work-sans-latin-var.woff2";
50 Added: "courier-prime-400-latin.woff2";
51 Added: "courier-prime-700-latin.woff2";
52 Added: ]
53 Added: in
54 Added: List.iter
55 Added: (fun face ->
56 Added: let response = get c ("/assets/fonts/" ^ face) in
57 Added: Alcotest.(check int)
58 Added: (Printf.sprintf "%s is served" face)
59 Added: 200 (status response);
60 Added: Alcotest.(check (option string))
61 Added: (Printf.sprintf "%s is woff2" face)
62 Added: (Some "font/woff2")
63 Added: (Dream.header response "Content-Type");
64 Added: (* The body is the real font: a non-empty payload whose first
65 Added: four bytes are the WOFF2 signature. *)
66 Added: let bytes = body response in
67 Added: Alcotest.(check bool)
68 Added: (Printf.sprintf "%s has a woff2 signature" face)
69 Added: true
70 Added: (String.length bytes >= 4 && String.sub bytes 0 4 = "wOF2"))
71 Added: faces;
72 Added: let missing = get c "/assets/fonts/does-not-exist.woff2" in
73 Added: Alcotest.(check int)
74 Added: "an unknown font is not found" 404 (status missing) );
38 75 ( "beginning before recovery is refused without an override",
39 76 `Quick,
40 77 fun () ->