4.2 KiB · text History b85db9e
//! Embedded, pre-compressed web assets.
//!
//! `build.rs` places these in `OUT_DIR` — either by copying the pre-built files
//! checked into `assets/`, or, with `feature = "rebuild-assets"`, by running
//! the Tailwind/DaisyUI pipeline over `web/`. Either way the bytes land in
//! flash via `include_bytes!` and are served with `Content-Encoding: gzip`
//! (woff2 is already compressed, so it is served as-is).
//!
//! This module is host-buildable: it is only bytes and header tables.

/// A static response body plus the headers it should be served with.
#[derive(Clone, Copy)]
pub struct Asset {
    pub bytes: &'static [u8],
    pub headers: &'static [(&'static str, &'static str)],
}

pub const H_HTML_GZ: &[(&str, &str)] = &[
    ("Content-Type", "text/html; charset=utf-8"),
    ("Content-Encoding", "gzip"),
];
pub const H_CSS_GZ: &[(&str, &str)] = &[
    ("Content-Type", "text/css"),
    ("Content-Encoding", "gzip"),
    ("Cache-Control", "max-age=86400"),
];
pub const H_WOFF2: &[(&str, &str)] = &[
    ("Content-Type", "font/woff2"),
    ("Cache-Control", "max-age=604800"),
];
pub const H_JSON: &[(&str, &str)] = &[("Content-Type", "application/json")];

/// The provisioning form. With `feature = "mqtt"` this is the variant that
/// includes the broker section.
pub const PORTAL_HTML: Asset = Asset {
    bytes: include_bytes!(concat!(env!("OUT_DIR"), "/portal.html.gz")),
    headers: H_HTML_GZ,
};

/// The "saved, rebooting" confirmation page.
pub const SAVED_HTML: Asset = Asset {
    bytes: include_bytes!(concat!(env!("OUT_DIR"), "/saved.html.gz")),
    headers: H_HTML_GZ,
};

/// A generic status page. Consumers usually pass their own instead.
pub const STATUS_HTML: Asset = Asset {
    bytes: include_bytes!(concat!(env!("OUT_DIR"), "/status.html.gz")),
    headers: H_HTML_GZ,
};

pub const APP_CSS: Asset = Asset {
    bytes: include_bytes!(concat!(env!("OUT_DIR"), "/app.css.gz")),
    headers: H_CSS_GZ,
};

pub const FONT_400: Asset = Asset {
    bytes: include_bytes!(concat!(env!("OUT_DIR"), "/font-400.woff2")),
    headers: H_WOFF2,
};

pub const FONT_700: Asset = Asset {
    bytes: include_bytes!(concat!(env!("OUT_DIR"), "/font-700.woff2")),
    headers: H_WOFF2,
};

/// The style shell every page needs. Both the portal and the status server
/// register these, so the two share one look without duplicating bytes.
pub const SHELL: &[(&str, Asset)] = &[
    ("/app.css", APP_CSS),
    ("/font-400.woff2", FONT_400),
    ("/font-700.woff2", FONT_700),
];

#[cfg(test)]
mod tests {
    use super::*;

    /// Guards against build.rs silently emitting an empty or missing file.
    #[test]
    fn assets_are_non_empty() {
        for (name, asset) in [
            ("portal.html.gz", PORTAL_HTML),
            ("saved.html.gz", SAVED_HTML),
            ("status.html.gz", STATUS_HTML),
            ("app.css.gz", APP_CSS),
            ("font-400.woff2", FONT_400),
            ("font-700.woff2", FONT_700),
        ] {
            assert!(!asset.bytes.is_empty(), "{name} is empty");
        }
    }

    #[test]
    fn gzipped_assets_have_gzip_magic() {
        for asset in [PORTAL_HTML, SAVED_HTML, STATUS_HTML, APP_CSS] {
            assert_eq!(&asset.bytes[0..2], &[0x1f, 0x8b], "not gzip data");
        }
    }

    #[test]
    fn fonts_are_woff2() {
        for asset in [FONT_400, FONT_700] {
            assert_eq!(&asset.bytes[0..4], b"wOF2");
        }
    }

    /// Pins the feature -> page mapping build.rs implements. Skipped under
    /// `rebuild-assets`, where the bytes come from a fresh Tailwind run and
    /// need not match the checked-in copies.
    #[test]
    #[cfg(not(feature = "rebuild-assets"))]
    fn mqtt_feature_selects_the_matching_portal_page() {
        #[cfg(feature = "mqtt")]
        const EXPECTED: &[u8] = include_bytes!("../assets/portal-mqtt.html.gz");
        #[cfg(not(feature = "mqtt"))]
        const EXPECTED: &[u8] = include_bytes!("../assets/portal-wifi.html.gz");

        assert_eq!(
            PORTAL_HTML.bytes, EXPECTED,
            "build.rs copied the wrong portal variant for this feature set"
        );
    }

    #[test]
    fn shell_paths_are_absolute() {
        for (path, _) in SHELL {
            assert!(path.starts_with('/'), "{path} must be an absolute URI");
        }
    }
}