1use crate::asset_snapshot::AssetSnapshot;
27use crate::resolve::embed_renderer::html_escape_attr;
28use crate::resolve::title_params::TitleParams;
29
30const CLASS_EMBED: &str = "moss-embed";
33
34#[allow(unused_variables)]
42pub fn synthesize_model_html(
43 params: &TitleParams,
44 src: &str,
45 assets: &AssetSnapshot,
46) -> String {
47 let data_width = match params.get("data-width") {
48 Some(w) => format!(r#" data-width="{}""#, html_escape_attr(w)),
49 None => String::new(),
50 };
51
52 let flags = collect_flag_attrs(params);
53 let style = collect_style_attr(params);
54
55 format!(
56 r#"<model-viewer class="{class}" data-type="3d"{data_width} src="{src}"{flags} touch-action="pan-y" loading="lazy"{style}></model-viewer>"#,
57 class = CLASS_EMBED,
58 data_width = data_width,
59 src = html_escape_attr(src),
60 flags = flags,
61 style = style,
62 )
63}
64
65fn collect_flag_attrs(params: &TitleParams) -> String {
72 const FLAGS: &[(&str, bool)] = &[
74 ("camera-controls", true),
75 ("auto-rotate", true),
76 ("ar", false),
77 ];
78 let mut out = String::new();
79 for (flag, default_on) in FLAGS {
80 let emit = match params.get(flag) {
81 Some("true") => true,
82 Some("false") => false,
83 _ => *default_on,
84 };
85 if emit {
86 out.push(' ');
87 out.push_str(flag);
88 }
89 }
90 out
91}
92
93fn collect_style_attr(params: &TitleParams) -> String {
99 let w = params.get("width");
100 let h = params.get("height");
101 match (w, h) {
102 (Some(w), Some(h)) => format!(
103 r#" style="width:{};height:{}""#,
104 html_escape_attr(w),
105 html_escape_attr(h),
106 ),
107 (Some(w), None) => format!(r#" style="width:{}""#, html_escape_attr(w)),
108 (None, Some(h)) => format!(r#" style="height:{}""#, html_escape_attr(h)),
109 (None, None) => String::new(),
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 fn empty_snapshot() -> AssetSnapshot {
118 AssetSnapshot::new()
119 }
120
121 fn params_with(kvs: &[(&str, &str)]) -> TitleParams {
122 let mut p = TitleParams::default();
123 for (k, v) in kvs {
124 p.insert(*k, *v);
125 }
126 p
127 }
128
129 #[test]
130 fn model_basic_shape() {
131 let p = params_with(&[("kind", "3d")]);
132 let out = synthesize_model_html(&p, "scene.glb", &empty_snapshot());
133 assert!(out.contains("<model-viewer"), "got: {}", out);
134 assert!(out.contains(r#"src="scene.glb""#));
135 }
136
137 #[test]
138 fn model_with_camera_controls() {
139 let p = params_with(&[("kind", "3d"), ("camera-controls", "true")]);
140 let out = synthesize_model_html(&p, "scene.glb", &empty_snapshot());
141 assert!(out.contains("camera-controls"));
142 }
143
144 #[test]
145 fn model_with_auto_rotate() {
146 let p = params_with(&[("kind", "3d"), ("auto-rotate", "true")]);
147 let out = synthesize_model_html(&p, "scene.glb", &empty_snapshot());
148 assert!(out.contains("auto-rotate"));
149 }
150
151 #[test]
152 fn model_with_ar() {
153 let p = params_with(&[("kind", "3d"), ("ar", "true")]);
154 let out = synthesize_model_html(&p, "scene.glb", &empty_snapshot());
155 assert!(out.contains(" ar"));
156 }
157
158 #[test]
159 fn model_escapes_url() {
160 let p = params_with(&[("kind", "3d")]);
161 let out = synthesize_model_html(&p, r#"scene with "spaces".glb"#, &empty_snapshot());
162 assert!(
164 !out.contains(r#"src="scene with """#),
165 "raw quote not escaped, got: {}",
166 out
167 );
168 }
169
170 #[test]
173 fn model_emits_moss_embed_class_and_data_type() {
174 let p = params_with(&[("kind", "3d")]);
175 let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
176 assert!(
177 out.contains(r#"class="moss-embed" data-type="3d""#),
178 "got: {}",
179 out
180 );
181 }
182
183 #[test]
184 fn model_emits_touch_action_and_loading() {
185 let p = params_with(&[("kind", "3d")]);
188 let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
189 assert!(out.contains(r#"touch-action="pan-y""#), "got: {}", out);
190 assert!(out.contains(r#"loading="lazy""#), "got: {}", out);
191 }
192
193 #[test]
194 fn model_emits_width_style() {
195 let p = params_with(&[("kind", "3d"), ("width", "400px")]);
198 let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
199 assert!(out.contains(r#"style="width:400px""#), "got: {}", out);
200 }
201
202 #[test]
203 fn model_emits_width_and_height_style() {
204 let p = params_with(&[("kind", "3d"), ("width", "400px"), ("height", "400px")]);
205 let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
206 assert!(
207 out.contains(r#"style="width:400px;height:400px""#),
208 "got: {}",
209 out
210 );
211 }
212
213 #[test]
214 fn model_emits_data_width_wrapper_attr() {
215 let p = params_with(&[("kind", "3d"), ("data-width", "wide")]);
218 let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
219 assert!(out.contains(r#"data-width="wide""#), "got: {}", out);
220 }
221
222 #[test]
223 fn model_suppresses_default_flag_when_param_false() {
224 let p = params_with(&[("kind", "3d"), ("camera-controls", "false")]);
226 let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
227 assert!(!out.contains("camera-controls"), "got: {}", out);
228 assert!(out.contains("auto-rotate"), "got: {}", out);
230 }
231
232 #[test]
233 fn model_defaults_emit_camera_controls_and_auto_rotate() {
234 let p = params_with(&[("kind", "3d")]);
237 let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
238 assert!(out.contains("camera-controls"), "got: {}", out);
239 assert!(out.contains("auto-rotate"), "got: {}", out);
240 assert!(!out.contains(" ar"), "ar must stay opt-in, got: {}", out);
241 }
242
243 #[test]
244 fn model_closes_tag() {
245 let p = params_with(&[("kind", "3d")]);
246 let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
247 assert!(out.ends_with("></model-viewer>"), "got: {}", out);
248 }
249}