presolve_compiler/
document_template.rs1use std::fmt;
4
5pub const DOCUMENT_TEMPLATE_PLACEHOLDERS_V1: [&str; 3] =
7 ["{{ head }}", "{{ app }}", "{{ runtime }}"];
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct DocumentTemplateErrorV1 {
11 pub code: &'static str,
12 pub message: String,
13}
14
15impl fmt::Display for DocumentTemplateErrorV1 {
16 fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
17 write!(output, "{}: {}", self.code, self.message)
18 }
19}
20
21impl std::error::Error for DocumentTemplateErrorV1 {}
22
23pub fn apply_document_template_v1(
29 template: &str,
30 generated_page: &str,
31 additional_head: &str,
32) -> Result<String, DocumentTemplateErrorV1> {
33 validate_template(template)?;
34 let (head, app, runtime) = split_generated_page(generated_page)?;
35 let head = format!("{head}{additional_head}");
36 let mut replacements = [
37 (
38 template
39 .find("{{ head }}")
40 .expect("validated template has one head placeholder"),
41 "{{ head }}",
42 head.as_str(),
43 ),
44 (
45 template
46 .find("{{ app }}")
47 .expect("validated template has one app placeholder"),
48 "{{ app }}",
49 app,
50 ),
51 (
52 template
53 .find("{{ runtime }}")
54 .expect("validated template has one runtime placeholder"),
55 "{{ runtime }}",
56 runtime,
57 ),
58 ];
59 replacements.sort_by_key(|(offset, _, _)| *offset);
60
61 let mut output = String::with_capacity(template.len() + head.len() + app.len() + runtime.len());
62 let mut cursor = 0;
63 for (offset, placeholder, value) in replacements {
64 output.push_str(&template[cursor..offset]);
65 output.push_str(value);
66 cursor = offset + placeholder.len();
67 }
68 output.push_str(&template[cursor..]);
69 Ok(output)
70}
71
72fn validate_template(template: &str) -> Result<(), DocumentTemplateErrorV1> {
73 if !template.trim_start().starts_with("<!doctype html>") || !template.contains("<html") {
74 return Err(DocumentTemplateErrorV1 {
75 code: "PSDOC1001_TEMPLATE_DOCUMENT_INVALID",
76 message: "app/index.html must declare an HTML document".into(),
77 });
78 }
79 for placeholder in DOCUMENT_TEMPLATE_PLACEHOLDERS_V1 {
80 if template.matches(placeholder).count() != 1 {
81 return Err(DocumentTemplateErrorV1 {
82 code: "PSDOC1002_TEMPLATE_PLACEHOLDER_COUNT",
83 message: format!("{placeholder} must occur exactly once"),
84 });
85 }
86 }
87 let mut remaining = template;
88 while let Some(start) = remaining.find("{{") {
89 let suffix = &remaining[start..];
90 let Some(end) = suffix.find("}}") else {
91 return Err(DocumentTemplateErrorV1 {
92 code: "PSDOC1003_TEMPLATE_PLACEHOLDER_INVALID",
93 message: "unclosed document-template placeholder".into(),
94 });
95 };
96 let placeholder = &suffix[..end + 2];
97 if !DOCUMENT_TEMPLATE_PLACEHOLDERS_V1.contains(&placeholder) {
98 return Err(DocumentTemplateErrorV1 {
99 code: "PSDOC1003_TEMPLATE_PLACEHOLDER_INVALID",
100 message: format!("unsupported document-template placeholder {placeholder}"),
101 });
102 }
103 remaining = &suffix[end + 2..];
104 }
105 Ok(())
106}
107
108fn split_generated_page(page: &str) -> Result<(&str, &str, &str), DocumentTemplateErrorV1> {
109 let head_start = " <head>\n";
110 let head_end = " </head>\n";
111 let body_start = " <body>\n";
112 let runtime_start =
113 " <script type=\"application/json\" id=\"presolve-template-manifest\">\n";
114 let body_end = " </body>\n</html>\n";
115 let Some(head_after) = page.find(head_start).map(|index| index + head_start.len()) else {
116 return generated_page_error();
117 };
118 let Some(head_end_index) = page[head_after..]
119 .find(head_end)
120 .map(|index| head_after + index)
121 else {
122 return generated_page_error();
123 };
124 let body_after = head_end_index + head_end.len();
125 if !page[body_after..].starts_with(body_start) {
126 return generated_page_error();
127 }
128 let app_after = body_after + body_start.len();
129 let Some(runtime_index) = page[app_after..]
130 .find(runtime_start)
131 .map(|index| app_after + index)
132 else {
133 return generated_page_error();
134 };
135 let Some(body_end_index) = page[runtime_index..]
136 .find(body_end)
137 .map(|index| runtime_index + index)
138 else {
139 return generated_page_error();
140 };
141 Ok((
142 &page[head_after..head_end_index],
143 &page[app_after..runtime_index],
144 &page[runtime_index..body_end_index],
145 ))
146}
147
148fn generated_page_error<T>() -> Result<T, DocumentTemplateErrorV1> {
149 Err(DocumentTemplateErrorV1 {
150 code: "PSDOC1004_GENERATED_PAGE_INVALID",
151 message: "compiler-generated page does not have the expected document structure".into(),
152 })
153}
154
155#[cfg(test)]
156mod tests {
157 use super::apply_document_template_v1;
158
159 const PAGE: &str = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <title>Home</title>\n </head>\n <body>\n <main>Home</main>\n <script type=\"application/json\" id=\"presolve-template-manifest\">\n {}\n </script>\n <script src=\"./runtime.js\" defer></script>\n </body>\n</html>\n";
160
161 #[test]
162 fn projects_compiler_owned_parts_into_the_required_placeholders() {
163 let output = apply_document_template_v1(
164 "<!doctype html>\n<html lang=\"en\">\n<head>\n{{ head }}\n</head>\n<body>\n{{ app }}{{ runtime }}\n</body>\n</html>\n",
165 PAGE,
166 " <link rel=\"stylesheet\" href=\"/app.css\">\n",
167 )
168 .unwrap();
169 assert!(output.contains("<head>\n <meta charset"));
170 assert!(output.contains("href=\"/app.css\""));
171 assert!(output.contains("<main>Home</main>"));
172 assert!(output.contains("presolve-template-manifest"));
173 assert!(!output.contains("{{ head }}"));
174 }
175
176 #[test]
177 fn rejects_missing_or_unknown_placeholders() {
178 assert_eq!(
179 apply_document_template_v1(
180 "<!doctype html><html>{{ head }}{{ app }}</html>",
181 PAGE,
182 "",
183 )
184 .unwrap_err()
185 .code,
186 "PSDOC1002_TEMPLATE_PLACEHOLDER_COUNT"
187 );
188 assert_eq!(
189 apply_document_template_v1(
190 "<!doctype html><html>{{ head }}{{ app }}{{ runtime }}{{ title }}</html>",
191 PAGE,
192 "",
193 )
194 .unwrap_err()
195 .code,
196 "PSDOC1003_TEMPLATE_PLACEHOLDER_INVALID"
197 );
198 }
199
200 #[test]
201 fn preserves_document_placeholder_spelling_inside_rendered_application_content() {
202 let page = PAGE.replace(
203 "<main>Home</main>",
204 "<main><code>{{ head }} {{ app }} {{ runtime }}</code></main>",
205 );
206 let output = apply_document_template_v1(
207 "<!doctype html>\n<html lang=\"en\">\n<head>{{ head }}</head>\n<body>{{ app }}{{ runtime }}</body>\n</html>\n",
208 &page,
209 "",
210 )
211 .unwrap();
212
213 assert!(output.contains("<code>{{ head }} {{ app }} {{ runtime }}</code>"));
214 assert_eq!(output.matches("presolve-template-manifest").count(), 1);
215 assert_eq!(output.matches("{{ runtime }}").count(), 1);
216 }
217}