1#![doc = include_str!("../README.md")]
2
3use std::sync::LazyLock;
4
5use anyhow::{Context, Result, bail};
6use regex::Regex;
7use turbo_unix_path::{get_parent_path, get_relative_path_to, join_path, normalize_path};
8
9pub fn expand_next_js_template<'a>(
17 content: &str,
18 template_path: &str,
19 next_package_dir_path: &str,
20 replacements: impl IntoIterator<Item = (&'a str, &'a str)>,
21 injections: impl IntoIterator<Item = (&'a str, &'a str)>,
22 imports: impl IntoIterator<Item = (&'a str, Option<&'a str>)>,
23) -> Result<String> {
24 let template_parent_path = normalize_path(get_parent_path(template_path))
25 .context("failed to normalize template path")?;
26 let next_package_dir_parent_path = normalize_path(get_parent_path(next_package_dir_path))
27 .context("failed to normalize package dir path")?;
28
29 fn replace_all<E>(
31 re: ®ex::Regex,
32 haystack: &str,
33 mut replacement: impl FnMut(®ex::Captures) -> Result<String, E>,
34 ) -> Result<String, E> {
35 let mut new = String::with_capacity(haystack.len());
36 let mut last_match = 0;
37 for caps in re.captures_iter(haystack) {
38 let m = caps.get(0).unwrap();
39 new.push_str(&haystack[last_match..m.start()]);
40 new.push_str(&replacement(&caps)?);
41 last_match = m.end();
42 }
43 new.push_str(&haystack[last_match..]);
44 Ok(new)
45 }
46
47 static IMPORT_PATH_RE: LazyLock<Regex> =
50 LazyLock::new(|| Regex::new("(?:from '(\\..*)'|import '(\\..*)')").unwrap());
51
52 let mut count = 0;
53 let mut content = replace_all(&IMPORT_PATH_RE, content, |caps| {
54 let from_request = caps.get(1).map_or("", |c| c.as_str());
55 count += 1;
56 let is_from_request = !from_request.is_empty();
57
58 let imported_path = join_path(
59 &template_parent_path,
60 if is_from_request {
61 from_request
62 } else {
63 caps.get(2).context("import path must exist")?.as_str()
64 },
65 )
66 .context("path should not leave the fs")?;
67
68 let relative = get_relative_path_to(&next_package_dir_parent_path, &imported_path);
69
70 if !relative.starts_with("./next/") {
71 bail!(
72 "Invariant: Expected relative import to start with \"./next/\", found \
73 {relative:?}. Path computed from {next_package_dir_parent_path:?} to \
74 {imported_path:?}.",
75 )
76 }
77
78 let relative = relative
79 .strip_prefix("./")
80 .context("should be able to strip the prefix")?;
81
82 Ok(if is_from_request {
83 format!("from {}", serde_json::to_string(relative).unwrap())
84 } else {
85 format!("import {}", serde_json::to_string(relative).unwrap())
86 })
87 })
88 .context("replacing imports failed")?;
89
90 if count == 0 {
94 bail!("Invariant: Expected to replace at least one import")
95 }
96
97 let mut missing_replacements = Vec::new();
100 for (key, replacement) in replacements {
101 let full = format!("'{key}'");
102
103 if content.contains(&full) {
104 content = content.replace(&full, &serde_json::to_string(&replacement).unwrap());
105 } else {
106 missing_replacements.push(key)
107 }
108 }
109
110 static TEMPLATE_VAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new("VAR_[A-Z_]+").unwrap());
112 let mut matches = TEMPLATE_VAR_RE.find_iter(&content).peekable();
113
114 if matches.peek().is_some() {
115 bail!(
116 "Invariant: Expected to replace all template variables, found {}",
117 matches.map(|m| m.as_str()).collect::<Vec<_>>().join(", "),
118 )
119 }
120
121 if !missing_replacements.is_empty() {
123 bail!(
124 "Invariant: Expected to replace all template variables, missing {} in template",
125 missing_replacements.join(", "),
126 )
127 }
128
129 let mut missing_injections = Vec::new();
131 for (key, injection) in injections {
132 let full = format!("// INJECT:{key}");
133
134 if content.contains(&full) {
135 content = content.replace(&full, &format!("const {key} = {injection}"));
136 } else {
137 missing_injections.push(key);
138 }
139 }
140
141 static INJECT_RE: LazyLock<Regex> =
143 LazyLock::new(|| Regex::new("// INJECT:[A-Za-z0-9_]+").unwrap());
144 let mut matches = INJECT_RE.find_iter(&content).peekable();
145
146 if matches.peek().is_some() {
147 bail!(
148 "Invariant: Expected to inject all injections, found {}",
149 matches.map(|m| m.as_str()).collect::<Vec<_>>().join(", "),
150 )
151 }
152
153 if !missing_injections.is_empty() {
155 bail!(
156 "Invariant: Expected to inject all injections, missing {} in template",
157 missing_injections.join(", "),
158 )
159 }
160
161 let mut missing_imports = Vec::new();
163 for (key, import_path) in imports {
164 let mut full = format!("// OPTIONAL_IMPORT:{key}");
165 let namespace = if !content.contains(&full) {
166 full = format!("// OPTIONAL_IMPORT:* as {key}");
167 if content.contains(&full) {
168 true
169 } else {
170 missing_imports.push(key);
171 continue;
172 }
173 } else {
174 false
175 };
176
177 if let Some(path) = import_path {
178 content = content.replace(
179 &full,
180 &format!(
181 "import {}{} from {}",
182 if namespace { "* as " } else { "" },
183 key,
184 serde_json::to_string(&path).unwrap(),
185 ),
186 );
187 } else {
188 content = content.replace(&full, &format!("const {key} = null"));
189 }
190 }
191
192 static OPTIONAL_IMPORT_RE: LazyLock<Regex> =
194 LazyLock::new(|| Regex::new("// OPTIONAL_IMPORT:(\\* as )?[A-Za-z0-9_]+").unwrap());
195 let mut matches = OPTIONAL_IMPORT_RE.find_iter(&content).peekable();
196
197 if matches.peek().is_some() {
198 bail!(
199 "Invariant: Expected to inject all imports, found {}",
200 matches.map(|m| m.as_str()).collect::<Vec<_>>().join(", "),
201 )
202 }
203
204 if !missing_imports.is_empty() {
206 bail!(
207 "Invariant: Expected to inject all imports, missing {} in template",
208 missing_imports.join(", "),
209 )
210 }
211
212 if !content.ends_with('\n') {
214 content.push('\n');
215 }
216
217 Ok(content)
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn test_expand_next_js_template() {
226 let input = r#"
227 import '../../foo/bar';
228 import * as userlandPage from 'VAR_USERLAND'
229 // OPTIONAL_IMPORT:* as userland500Page
230 // OPTIONAL_IMPORT:incrementalCacheHandler
231
232 // INJECT:nextConfig
233 const srcPage = 'VAR_PAGE'
234 "#;
235
236 let expected = r#"
237 import "next/src/foo/bar";
238 import * as userlandPage from "INNER_PAGE_ENTRY"
239 import * as userland500Page from "INNER_ERROR_500"
240 const incrementalCacheHandler = null
241
242 const nextConfig = {}
243 const srcPage = "./some/path.js"
244 "#;
245
246 let output = expand_next_js_template(
247 input,
248 "project/node_modules/next/src/build/templates/test-case.js",
249 "project/node_modules/next",
250 [
251 ("VAR_USERLAND", "INNER_PAGE_ENTRY"),
252 ("VAR_PAGE", "./some/path.js"),
253 ],
254 [("nextConfig", "{}")],
255 [
256 ("incrementalCacheHandler", None),
257 ("userland500Page", Some("INNER_ERROR_500")),
258 ],
259 )
260 .unwrap();
261 println!("{output}");
262
263 assert_eq!(output.trim_end(), expected.trim_end());
264 }
265}