ferrovue 0.9.0

Vue templates compiled to Rust render functions: server-side rendering with no JavaScript at run time
Documentation
use super::*;

use serde_json::Value;

fn decoded(v: &Value) -> HeadValue {
    match v {
        Value::Null => HeadValue::Null,
        Value::Bool(b) => HeadValue::Bool(*b),
        Value::Number(n) => HeadValue::Number(n.as_f64().expect("a number")),
        Value::String(s) => HeadValue::Str(s.clone()),
        Value::Array(items) => HeadValue::Array(items.iter().map(decoded).collect()),
        Value::Object(o) => {
            if o.contains_key("$u") {
                return HeadValue::Undefined;
            }
            if let Some(Value::String(n)) = o.get("$n") {
                return HeadValue::Number(match n.as_str() {
                    "NaN" => f64::NAN,
                    "Infinity" => f64::INFINITY,
                    "-Infinity" => f64::NEG_INFINITY,
                    "-0" => -0.0,
                    other => panic!("no number {other}"),
                });
            }
            let Some(Value::Array(pairs)) = o.get("$o") else {
                panic!("not an encoded value: {v}");
            };
            HeadValue::Object(
                pairs
                    .iter()
                    .map(|p| {
                        let key = p[0].as_str().expect("a key").to_owned();
                        (Cow::Owned(key), decoded(&p[1]))
                    })
                    .collect(),
            )
        }
    }
}

fn leaked(v: &Value) -> &'static str {
    Box::leak(v.as_str().expect("a string").to_owned().into_boxed_str())
}

#[test]
fn renders_every_head_as_unhead_rendered_it() {
    let vectors: Vec<Value> =
        serde_json::from_str(include_str!("../../tests/vectors/head.json")).expect("head vectors");
    assert!(vectors.len() >= 400, "the vectors were not all read");
    let mut failures = Vec::new();
    for (i, vector) in vectors.iter().enumerate() {
        let head = if vector["defaults"].as_bool().expect("defaults") {
            Head::new()
        } else {
            Head::without_defaults()
        };
        for call in vector["calls"].as_array().expect("calls") {
            if let Some(input) = call.get("head") {
                head.push(decoded(input));
            } else {
                let seo = &call["seo"];
                let meta = seo[1]
                    .as_array()
                    .expect("meta")
                    .iter()
                    .map(|m| (leaked(&m[0]), leaked(&m[1]), decoded(&m[2])))
                    .collect();
                head.push_seo_meta(decoded(&seo[0]), meta);
            }
        }
        let html = head.render();
        let want = &vector["expected"];
        let got = [
            ("headTags", &html.head_tags),
            ("bodyTags", &html.body_tags),
            ("bodyTagsOpen", &html.body_tags_open),
            ("htmlAttrs", &html.html_attrs),
            ("bodyAttrs", &html.body_attrs),
        ];
        for (field, value) in got {
            if want[field].as_str() != Some(value.as_str()) {
                failures.push(format!(
                    "vector {i}, {field}:\n  unhead: {}\n  ours:   {value:?}\n  calls: {}",
                    want[field], vector["calls"]
                ));
            }
        }
    }
    assert!(
        failures.is_empty(),
        "{} of {} fields differ:\n{}",
        failures.len(),
        vectors.len() * 5,
        failures[..failures.len().min(8)].join("\n")
    );
}

#[test]
fn orders_object_keys_as_javascript_does() {
    let HeadValue::Object(entries) = HeadValue::object([
        ("b", HeadValue::Null),
        ("10", HeadValue::Null),
        ("a", HeadValue::Null),
        ("2", HeadValue::Null),
        ("b", HeadValue::Bool(true)),
    ]) else {
        unreachable!()
    };
    let keys: Vec<&str> = entries.iter().map(|(k, _)| k.as_ref()).collect();
    assert_eq!(keys, ["2", "10", "b", "a"]);
    assert_eq!(entries[2].1, HeadValue::Bool(true));
}

#[test]
fn writes_nothing_for_an_input_that_is_not_an_object() {
    let head = Head::without_defaults();
    head.push(HeadValue::str("title"));
    head.push(HeadValue::Null);
    assert_eq!(head.render(), HeadHtml::default());
}

#[test]
fn places_an_array_index_after_the_smaller_ones() {
    let HeadValue::Object(entries) = HeadValue::object([
        ("1", HeadValue::Null),
        ("5", HeadValue::Null),
        ("x", HeadValue::Null),
        ("3", HeadValue::Null),
        ("7", HeadValue::Null),
    ]) else {
        unreachable!()
    };
    let keys: Vec<&str> = entries.iter().map(|(k, _)| k.as_ref()).collect();
    assert_eq!(keys, ["1", "3", "5", "7", "x"]);
}

#[test]
fn converts_rust_values_to_the_javascript_values_they_are() {
    assert_eq!(HeadValue::float(1.5), HeadValue::Number(1.5));
    assert_eq!(HeadValue::from("a"), HeadValue::Str("a".to_owned()));
    assert_eq!(
        HeadValue::from(String::from("b")),
        HeadValue::Str("b".to_owned())
    );
    assert_eq!(
        HeadValue::from(Cow::Borrowed("c")),
        HeadValue::Str("c".to_owned())
    );
    assert_eq!(HeadValue::from(false), HeadValue::Bool(false));
    assert_eq!(HeadValue::from(-3_i64), HeadValue::Number(-3.0));
    assert_eq!(HeadValue::from(0.25), HeadValue::Number(0.25));
    assert_eq!(HeadValue::from(Some(true)), HeadValue::Bool(true));
    assert_eq!(HeadValue::from(None::<bool>), HeadValue::Undefined);
}

#[test]
fn entries_pushed_while_deferred_come_after_the_others_level_by_level() {
    let head = Head::without_defaults();
    let meta = |name: &str| {
        HeadValue::object([(
            "meta",
            HeadValue::array([HeadValue::object([
                ("name", HeadValue::str(name)),
                ("content", HeadValue::str(name)),
            ])]),
        )])
    };
    head.push(meta("a"));
    {
        let _later = head.deferred();
        head.push(meta("c"));
        {
            let _later = head.deferred();
            head.push(meta("e"));
        }
        head.push(meta("d"));
    }
    head.push(meta("b"));
    let tags = head.render().head_tags;
    let names: Vec<&str> = tags.lines().map(|l| &l[12..13]).collect();
    assert_eq!(names, ["a", "b", "c", "d", "e"]);
}

#[test]
fn no_head_value_writes_the_hole_marker() {
    let s = HeadValue::str;
    for value in [
        "<fv-hole>",
        crate::slots::HOLE,
        &crate::slots::HOLE.to_uppercase(),
    ] {
        let head = Head::without_defaults();
        head.push(HeadValue::object([
            ("title", s(value)),
            (
                "htmlAttrs",
                HeadValue::object([("class", s(value)), ("data-x", s(value))]),
            ),
            (
                "bodyAttrs",
                HeadValue::object([("style", HeadValue::object([("color", s(value))]))]),
            ),
            (
                "meta",
                HeadValue::array([HeadValue::object([
                    ("name", s("description")),
                    ("content", s(value)),
                ])]),
            ),
            (
                "link",
                HeadValue::array([HeadValue::object([
                    ("rel", s("canonical")),
                    ("href", s(value)),
                ])]),
            ),
            (
                "script",
                HeadValue::array([
                    HeadValue::object([("innerHTML", s(value)), ("key", s("a"))]),
                    HeadValue::object([("textContent", s(value)), ("key", s("b"))]),
                    HeadValue::object([
                        ("type", s("application/ld+json")),
                        ("innerHTML", HeadValue::object([("name", s(value))])),
                    ]),
                ]),
            ),
            ("style", HeadValue::array([s(value)])),
            ("noscript", HeadValue::array([s(value)])),
        ]));
        let html = head.render();
        for part in [
            &html.head_tags,
            &html.body_tags,
            &html.body_tags_open,
            &html.html_attrs,
            &html.body_attrs,
        ] {
            assert!(!part.contains(crate::slots::HOLE), "{part}");
        }
        assert_eq!(html.head_tags.lines().count(), 8, "{}", html.head_tags);
    }
}