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 Ok(template
37 .replacen("{{ head }}", &head, 1)
38 .replacen("{{ app }}", app, 1)
39 .replacen("{{ runtime }}", runtime, 1))
40}
41
42fn validate_template(template: &str) -> Result<(), DocumentTemplateErrorV1> {
43 if !template.trim_start().starts_with("<!doctype html>") || !template.contains("<html") {
44 return Err(DocumentTemplateErrorV1 {
45 code: "PSDOC1001_TEMPLATE_DOCUMENT_INVALID",
46 message: "app/index.html must declare an HTML document".into(),
47 });
48 }
49 for placeholder in DOCUMENT_TEMPLATE_PLACEHOLDERS_V1 {
50 if template.matches(placeholder).count() != 1 {
51 return Err(DocumentTemplateErrorV1 {
52 code: "PSDOC1002_TEMPLATE_PLACEHOLDER_COUNT",
53 message: format!("{placeholder} must occur exactly once"),
54 });
55 }
56 }
57 let mut remaining = template;
58 while let Some(start) = remaining.find("{{") {
59 let suffix = &remaining[start..];
60 let Some(end) = suffix.find("}}") else {
61 return Err(DocumentTemplateErrorV1 {
62 code: "PSDOC1003_TEMPLATE_PLACEHOLDER_INVALID",
63 message: "unclosed document-template placeholder".into(),
64 });
65 };
66 let placeholder = &suffix[..end + 2];
67 if !DOCUMENT_TEMPLATE_PLACEHOLDERS_V1.contains(&placeholder) {
68 return Err(DocumentTemplateErrorV1 {
69 code: "PSDOC1003_TEMPLATE_PLACEHOLDER_INVALID",
70 message: format!("unsupported document-template placeholder {placeholder}"),
71 });
72 }
73 remaining = &suffix[end + 2..];
74 }
75 Ok(())
76}
77
78fn split_generated_page(page: &str) -> Result<(&str, &str, &str), DocumentTemplateErrorV1> {
79 let head_start = " <head>\n";
80 let head_end = " </head>\n";
81 let body_start = " <body>\n";
82 let runtime_start =
83 " <script type=\"application/json\" id=\"presolve-template-manifest\">\n";
84 let body_end = " </body>\n</html>\n";
85 let Some(head_after) = page.find(head_start).map(|index| index + head_start.len()) else {
86 return generated_page_error();
87 };
88 let Some(head_end_index) = page[head_after..]
89 .find(head_end)
90 .map(|index| head_after + index)
91 else {
92 return generated_page_error();
93 };
94 let body_after = head_end_index + head_end.len();
95 if !page[body_after..].starts_with(body_start) {
96 return generated_page_error();
97 }
98 let app_after = body_after + body_start.len();
99 let Some(runtime_index) = page[app_after..]
100 .find(runtime_start)
101 .map(|index| app_after + index)
102 else {
103 return generated_page_error();
104 };
105 let Some(body_end_index) = page[runtime_index..]
106 .find(body_end)
107 .map(|index| runtime_index + index)
108 else {
109 return generated_page_error();
110 };
111 Ok((
112 &page[head_after..head_end_index],
113 &page[app_after..runtime_index],
114 &page[runtime_index..body_end_index],
115 ))
116}
117
118fn generated_page_error<T>() -> Result<T, DocumentTemplateErrorV1> {
119 Err(DocumentTemplateErrorV1 {
120 code: "PSDOC1004_GENERATED_PAGE_INVALID",
121 message: "compiler-generated page does not have the expected document structure".into(),
122 })
123}
124
125#[cfg(test)]
126mod tests {
127 use super::apply_document_template_v1;
128
129 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";
130
131 #[test]
132 fn projects_compiler_owned_parts_into_the_required_placeholders() {
133 let output = apply_document_template_v1(
134 "<!doctype html>\n<html lang=\"en\">\n<head>\n{{ head }}\n</head>\n<body>\n{{ app }}{{ runtime }}\n</body>\n</html>\n",
135 PAGE,
136 " <link rel=\"stylesheet\" href=\"/app.css\">\n",
137 )
138 .unwrap();
139 assert!(output.contains("<head>\n <meta charset"));
140 assert!(output.contains("href=\"/app.css\""));
141 assert!(output.contains("<main>Home</main>"));
142 assert!(output.contains("presolve-template-manifest"));
143 assert!(!output.contains("{{ head }}"));
144 }
145
146 #[test]
147 fn rejects_missing_or_unknown_placeholders() {
148 assert_eq!(
149 apply_document_template_v1(
150 "<!doctype html><html>{{ head }}{{ app }}</html>",
151 PAGE,
152 "",
153 )
154 .unwrap_err()
155 .code,
156 "PSDOC1002_TEMPLATE_PLACEHOLDER_COUNT"
157 );
158 assert_eq!(
159 apply_document_template_v1(
160 "<!doctype html><html>{{ head }}{{ app }}{{ runtime }}{{ title }}</html>",
161 PAGE,
162 "",
163 )
164 .unwrap_err()
165 .code,
166 "PSDOC1003_TEMPLATE_PLACEHOLDER_INVALID"
167 );
168 }
169}