csaf-crud 1.5.26

CSAF 2.0 / 2.1 advisory CRUD server with HATEOAS JSON API and HTML UI (TLS 1.3, HTTP/1.1 + HTTP/2 + HTTP/3)
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! Small markup helpers shared by the server-rendered pages.

use crate::i18n::{self, Lang};

/// Version appended to every static asset URL.
///
/// Static files are served with a flat one-day `cache-control` and no
/// fingerprint, so a release changes the URL to make browsers refetch.
pub const ASSET_VERSION: &str = env!("CARGO_PKG_VERSION");

/// The three kinds of message box a page shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FlashKind {
    /// Something failed. Announced at once (`role="alert"`).
    Error,
    /// A static fact the page states (a configured directory). Not announced.
    Info,
    /// Something finished. Announced politely (`role="status"`).
    Success,
}

impl FlashKind {
    /// CSS modifier, sprite icon, live-region role and the spoken-word key.
    const fn parts(self) -> (&'static str, &'static str, &'static str, &'static str) {
        match self {
            Self::Error => ("error", "x-circle", r#" role="alert""#, "ui.flash_error"),
            Self::Info => ("info", "info-circle", "", "ui.flash_info"),
            Self::Success => (
                "success",
                "check-circle",
                r#" role="status""#,
                "ui.flash_success",
            ),
        }
    }
}

/// The opening of a message box: the `<div>`, an icon, and a word for
/// assistive technology, so colour is never the only signal. The caller
/// writes the body and the closing `</div>`.
#[must_use]
pub fn flash_open(kind: FlashKind, lang: Lang) -> String {
    let (class, symbol, role, word_key) = kind.parts();
    format!(
        r#"<div class="flash {class}"{role}>{icon}<span class="sr-only">{word}</span> "#,
        icon = icon(symbol),
        word = i18n::t(lang, word_key),
    )
}

/// A complete message box around an already-escaped HTML body.
#[must_use]
pub fn flash(kind: FlashKind, lang: Lang, body_html: &str) -> String {
    format!("{}{body_html}</div>", flash_open(kind, lang))
}

/// An inline icon from the self-hosted sprite (`static/img/icons.svg`).
///
/// Decorative: the SVG is hidden from assistive technology, so the visible
/// text next to it (or an `aria-label` on the control) carries the meaning.
/// The name is `&'static str` on purpose: only literals, never request data,
/// can reach the markup. A test checks that every name used exists.
#[must_use]
pub fn icon(name: &'static str) -> String {
    format!(
        r##"<svg class="icon" aria-hidden="true"><use href="/static/img/icons.svg?v={ASSET_VERSION}#{name}"></use></svg>"##
    )
}

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

    /// The sprite the server embeds.
    const SPRITE: &str = include_str!("../../static/img/icons.svg");

    #[test]
    fn an_icon_is_decorative_and_points_at_the_sprite_symbol() {
        let svg = icon("house");
        assert!(svg.contains(r#"aria-hidden="true""#), "{svg}");
        // `focusable` is not an HTML attribute (it was an IE/old-Edge hint);
        // every current browser leaves an inline SVG unfocusable by default.
        assert!(!svg.contains("focusable"), "{svg}");
        assert!(svg.contains("/static/img/icons.svg?v="), "{svg}");
        assert!(svg.ends_with("#house\"></use></svg>"), "{svg}");
    }

    #[test]
    fn an_error_flash_is_an_alert_with_an_icon_and_a_spoken_word() {
        // Colour alone must not carry the meaning (ndaal-design): an error
        // has an icon, a word for screen readers, and an assertive role.
        let html = flash_open(FlashKind::Error, Lang::En);
        assert!(
            html.starts_with(r#"<div class="flash error" role="alert">"#),
            "{html}"
        );
        assert!(html.contains("#x-circle"), "{html}");
        assert!(
            html.contains(r#"<span class="sr-only">Error:</span> "#),
            "{html}"
        );
    }

    #[test]
    fn a_success_flash_is_a_polite_status() {
        let html = flash_open(FlashKind::Success, Lang::En);
        assert!(
            html.starts_with(r#"<div class="flash success" role="status">"#),
            "{html}"
        );
        assert!(html.contains("#check-circle"), "{html}");
        assert!(
            html.contains(r#"<span class="sr-only">Success:</span> "#),
            "{html}"
        );
    }

    #[test]
    fn an_info_flash_announces_nothing() {
        // Static facts on a page (the configured directories) are content,
        // not an event: no live-region role, or every page load would speak.
        let html = flash_open(FlashKind::Info, Lang::En);
        assert!(html.starts_with(r#"<div class="flash info">"#), "{html}");
        assert!(!html.contains("role="), "{html}");
        assert!(html.contains("#info-circle"), "{html}");
    }

    #[test]
    fn flash_wraps_the_body_and_closes_the_box() {
        let html = flash(FlashKind::Error, Lang::En, "&lt;bad&gt;");
        assert!(html.ends_with("&lt;bad&gt;</div>"), "{html}");
    }

    #[test]
    fn the_sprite_is_inert_markup_only() {
        // SVG is served as an image document; it must hold shapes and
        // nothing that can run, style the page or reach another host.
        let lower = SPRITE.to_ascii_lowercase();
        for forbidden in [
            "<script",
            "<style",
            "onload",
            "onclick",
            "javascript:",
            "href=\"http",
            "<foreignobject",
        ] {
            assert!(
                !lower.contains(forbidden),
                "{forbidden} found in the sprite"
            );
        }
    }

    #[test]
    fn every_icon_the_pages_use_exists_in_the_sprite() {
        // Scan the route sources for icon("name") literals.
        let sources = [
            include_str!("layout.rs"),
            include_str!("home.rs"),
            include_str!("csaf.rs"),
            include_str!("admin.rs"),
            include_str!("settings.rs"),
            include_str!("info.rs"),
        ];
        let mut used = std::collections::BTreeSet::new();
        for source in sources {
            // Both call sites and `icon: "name"` fields of the nav table.
            for pattern in ["icon(\"", "icon: \""] {
                for part in source.split(pattern).skip(1) {
                    if let Some(name) = part.split('"').next() {
                        used.insert(name.to_owned());
                    }
                }
            }
        }
        assert!(
            used.len() >= 8,
            "expected the pages to use several icons: {used:?}"
        );
        for name in &used {
            assert!(
                SPRITE.contains(&format!(r#"<symbol id="{name}" viewBox"#)),
                "icon {name} is used but missing from icons.svg"
            );
        }
    }
}