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