Skip to main content

topcoat_font/
router.rs

1use std::sync::OnceLock;
2
3use topcoat_core::context::Cx;
4use topcoat_router::{
5    Body, HeaderValue, Method, Methods, Path, PathBuf, Route, RouteFuture, RouteId, RouterBuilder,
6    header::{CACHE_CONTROL, CONTENT_TYPE},
7    response::Response,
8};
9
10use crate::{Font, FontResolver};
11
12const FONT_ROUTE_PREFIX: &str = "/_topcoat/fonts";
13
14/// `Cache-Control` applied to every served font. Bundled fonts carry a
15/// content hash, so their contents never change for a given URL.
16const CACHE_CONTROL_VALUE: HeaderValue =
17    HeaderValue::from_static("public, max-age=31536000, immutable");
18
19/// The URL path a font's stylesheet is served at, e.g.
20/// `/_topcoat/fonts/Lavishly-Yours-1a2b3c4d5e6f7a8b.css`.
21///
22/// The family name is slugified to stay URL-safe (so the served route and the
23/// rendered `href` match without percent-encoding), and a content hash keeps the
24/// URL immutable for a given set of faces.
25fn font_route_path(font: Font, write: &mut dyn std::fmt::Write) -> std::fmt::Result {
26    write.write_str(FONT_ROUTE_PREFIX)?;
27    write.write_str("/")?;
28    for ch in font.family().chars() {
29        write.write_char(if ch.is_ascii_alphanumeric() { ch } else { '-' })?;
30    }
31    write!(write, "-{:016x}.css", font.hash())
32}
33
34pub struct FontRoute {
35    id: RouteId,
36    path: PathBuf,
37    font: Font,
38    cache: OnceLock<String>,
39}
40
41impl FontRoute {
42    #[must_use]
43    pub fn new(font: Font) -> Self {
44        let mut path = String::new();
45        let _ = font_route_path(font, &mut path);
46        Self {
47            id: RouteId::new(),
48            path: Path::new(&path).to_owned(),
49            font,
50            cache: OnceLock::new(),
51        }
52    }
53}
54
55impl Route for FontRoute {
56    fn id(&self) -> RouteId {
57        self.id
58    }
59
60    fn methods(&self) -> Methods<'_> {
61        Methods::Only(&[Method::GET])
62    }
63
64    fn path(&self) -> &Path {
65        &self.path
66    }
67
68    fn handle<'cx>(&'cx self, cx: &'cx Cx, _body: Body) -> RouteFuture<'cx> {
69        Box::pin(async {
70            // Render the `@font-face` CSS once and cache the result.
71            let cached_css = self.cache.get_or_init(|| {
72                let mut css = String::new();
73                let _ = self.font.faces().fmt(cx, &mut css);
74                css
75            });
76
77            let mut response = Response::new(Body::from(cached_css.clone()));
78            let headers = response.headers_mut();
79            headers.insert(
80                CONTENT_TYPE,
81                HeaderValue::from_static("text/css; charset=utf-8"),
82            );
83            headers.insert(CACHE_CONTROL, CACHE_CONTROL_VALUE);
84            Ok(response)
85        })
86    }
87}
88
89pub trait RouterBuilderFontExt {
90    #[must_use]
91    fn font(self, font: Font) -> Self;
92
93    #[cfg(feature = "discover")]
94    #[must_use]
95    fn discover_fonts(self) -> Self;
96}
97
98impl RouterBuilderFontExt for RouterBuilder {
99    fn font(mut self, font: Font) -> Self {
100        self = self.route(FontRoute::new(font));
101        // Every font shares the same resolver, so register it only for the
102        // first one; a second `app_context` of the same type would panic.
103        if self.get_app_context::<FontResolver>().is_none() {
104            self = self.app_context(FontResolver::new(Box::new(font_route_path)));
105        }
106        self
107    }
108
109    #[cfg(feature = "discover")]
110    fn discover_fonts(mut self) -> Self {
111        for font in inventory::iter::<crate::Font> {
112            self = self.font(*font);
113        }
114        self
115    }
116}