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