Skip to main content

sofie_engine/client/
mod.rs

1//! Screens compiled into a client framework's components when the app is built.
2
3mod emit;
4mod script;
5mod tree;
6
7use std::fmt;
8
9use crate::{Components, Template, TemplateError};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum Client {
13    Svelte,
14}
15
16impl Client {
17    /// As the web toolchain names the client's adapter.
18    #[must_use]
19    pub fn name(self) -> &'static str {
20        match self {
21            Self::Svelte => "svelte",
22        }
23    }
24
25    #[must_use]
26    pub fn extension(self) -> &'static str {
27        match self {
28            Self::Svelte => "svelte",
29        }
30    }
31}
32
33impl fmt::Display for Client {
34    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35        formatter.write_str(match self {
36            Self::Svelte => "Svelte",
37        })
38    }
39}
40
41/// One screen as the source of a component taking `data` and `view` props.
42pub fn compile_client(
43    name: &str,
44    source: &str,
45    components: &Components,
46    client: Client,
47) -> Result<String, TemplateError> {
48    let template = Template::parse_with(name, source, components)?;
49    tree::tree(&template.ops)
50        .and_then(|nodes| emit::Emitter::new().screen(&nodes))
51        .map_err(|message| {
52            TemplateError::unlocated(name, format!("compiling for {client}: {message}"))
53        })
54}
55
56#[cfg(test)]
57mod tests {
58    use super::{Client, compile_client};
59    use crate::Components;
60
61    const SOURCE: &str = "<Section title=\"Tom &amp; Jerry\">\
62        <ul class=\"list {data.kind}\">{#each data.items as item, index}\
63        <li><a href={item.url}>{item.name}</a></li>{/each}</ul>\
64        {#if data.empty}<p>None {\"{x}\"}</p>{:else}<input required>{/if}\
65        <Table id=\"t\" of={data.rows} columns=\"name\" as=\"row\">\
66        <Action post=\"/r/{row.id}\" label=\"Go\" /></Table></Section>";
67
68    fn compiled(client: Client) -> String {
69        compile_client("screen", SOURCE, &Components::default(), client).unwrap()
70    }
71
72    #[test]
73    fn svelte_keeps_markup_and_writes_blocks_and_snippets() {
74        let svelte = compiled(Client::Svelte);
75
76        for expected in [
77            "props={{\"title\": S.text(\"Tom & Jerry\")}}",
78            "<ul class=\"list {S.text(data?.kind)}\">",
79            "{#each S.items(data?.items) as s0, s1}<li><a href=\"{S.url(S.text(s0?.url))}\">",
80            "{S.text(s0?.name)}</a></li>{/each}",
81            "{#if S.truthy(data?.empty)}<p>None {S.text(\"{x}\")}</p>{:else}<input required=\"\">{/if}",
82            "{#snippet row(s0)}<Catalog {view} name=\"Action\" props={{\"post\": \
83             S.join([\"/r/\", S.urlPart(s0?.id)]), \"label\": S.text(\"Go\")}} />{/snippet}",
84        ] {
85            assert!(svelte.contains(expected), "{expected}\n{svelte}");
86        }
87    }
88
89    #[test]
90    fn an_action_passes_its_posted_fields_as_values() {
91        let source = "{#each data.slots as slot}<Action post=\"/slots/book\" label=\"Book\" \
92            with={{slot: slot.id, seats: 2}} />{/each}";
93
94        let svelte =
95            compile_client("slots", source, &Components::default(), Client::Svelte).unwrap();
96
97        assert!(
98            svelte.contains("\"with\": {\"slot\": s0?.id, \"seats\": 2}"),
99            "{svelte}"
100        );
101    }
102
103    #[test]
104    fn a_second_form_sharing_an_id_is_looked_up_under_its_own_key() {
105        let source = "{#if data.a}<Form id=\"f\" post=\"/f\"><Submit label=\"Go\" /></Form>\
106            {:else}<Form id=\"f\" post=\"/f\"><Submit label=\"Later\" /></Form>{/if}";
107
108        let svelte =
109            compile_client("forms", source, &Components::default(), Client::Svelte).unwrap();
110
111        assert_eq!(
112            svelte.matches("\"describedAs\": \"f#2\"").count(),
113            1,
114            "{svelte}"
115        );
116    }
117
118    #[test]
119    fn a_svelte_screen_s_title_names_the_page_through_its_head() {
120        let svelte = compile_client(
121            "order",
122            "<title>Order {data.id}</title><p>x</p>",
123            &Components::default(),
124            Client::Svelte,
125        )
126        .unwrap();
127
128        assert!(
129            svelte.contains(
130                "<svelte:head><title>Order {S.text(data?.id)}</title></svelte:head><p>x</p>"
131            ),
132            "{svelte}"
133        );
134    }
135
136    #[test]
137    fn written_pages_stay_in_html_areas() {
138        let error = compile_client(
139            "page",
140            "<Markdown text={data.body} />",
141            &Components::default(),
142            Client::Svelte,
143        )
144        .unwrap_err();
145
146        assert!(error.message.contains("only in HTML areas"), "{error}");
147    }
148
149    #[test]
150    fn page_script_attributes_are_kept_for_the_client_runtime() {
151        let svelte = compile_client(
152            "passkeys",
153            "<button type=\"button\" data-sofie-passkey=\"create\" data-options={data.options} \
154             data-post=\"passkeys\">Add</button><form method=\"post\" action={data.action} \
155             data-sofie-autosubmit=\"true\"></form>",
156            &Components::default(),
157            Client::Svelte,
158        )
159        .unwrap();
160
161        for expected in [
162            "<button type=\"button\" data-sofie-passkey=\"create\" \
163             data-options=\"{S.text(data?.options)}\" data-post=\"passkeys\">Add</button>",
164            "data-sofie-autosubmit=\"true\"",
165        ] {
166            assert!(svelte.contains(expected), "{expected}\n{svelte}");
167        }
168    }
169}