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> = LazyLock::new(|| {
97 Regex::new(r"(?:from '(\.[^']*)'|import '(\.[^']*)'|require\('(\.[^']*)'\))").unwrap()
98 });
99
100 let mut count = 0;
101 let mut content = replace_all(&IMPORT_PATH_RE, content, |caps| {
102 let capture = caps
103 .get(1)
104 .or_else(|| caps.get(2))
105 .or_else(|| caps.get(3))
106 .map(|c| c.as_str());
107 count += 1;
108
109 let imported_path = join_path(
110 &template_parent_path,
111 capture.context("import path must exist")?,
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 caps.get(1).is_some() {
130 format!("from {}", serde_json::to_string(relative).unwrap())
131 } else if caps.get(2).is_some() {
132 format!("import {}", serde_json::to_string(relative).unwrap())
133 } else {
134 format!("require({})", serde_json::to_string(relative).unwrap())
135 })
136 })
137 .context("replacing imports failed")?;
138
139 if require_import_replacement && count == 0 {
144 bail!("Invariant: Expected to replace at least one import")
145 }
146
147 let mut missing_replacements = Vec::new();
150 for (key, replacement) in replacements {
151 let full = format!("'{key}'");
152
153 if content.contains(&full) {
154 content = content.replace(&full, &serde_json::to_string(&replacement).unwrap());
155 } else {
156 missing_replacements.push(key)
157 }
158 }
159
160 static TEMPLATE_VAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new("VAR_[A-Z_]+").unwrap());
162 let mut matches = TEMPLATE_VAR_RE.find_iter(&content).peekable();
163
164 if matches.peek().is_some() {
165 bail!(
166 "Invariant: Expected to replace all template variables, found {}",
167 matches.map(|m| m.as_str()).collect::<Vec<_>>().join(", "),
168 )
169 }
170
171 if !missing_replacements.is_empty() {
173 bail!(
174 "Invariant: Expected to replace all template variables, missing {} in template",
175 missing_replacements.join(", "),
176 )
177 }
178
179 let mut missing_injections = Vec::new();
181 for (key, injection) in injections {
182 let mut used = false;
183 let full_raw = format!("// INJECT_RAW:{key}");
184
185 if content.contains(&full_raw) {
186 content = content.replace(&full_raw, injection);
187 used = true;
188 }
189
190 let full = format!("// INJECT:{key}");
191
192 if content.contains(&full) {
193 content = content.replace(&full, &format!("const {key} = {injection}"));
194 used = true;
195 }
196
197 if !used {
198 missing_injections.push(key);
199 }
200 }
201
202 static INJECT_RAW_RE: LazyLock<Regex> =
204 LazyLock::new(|| Regex::new("// INJECT_RAW:[A-Za-z0-9_]+").unwrap());
205 let mut matches = INJECT_RAW_RE.find_iter(&content).peekable();
206
207 if matches.peek().is_some() {
208 bail!(
209 "Invariant: Expected to inject all injections, found {}",
210 matches.map(|m| m.as_str()).collect::<Vec<_>>().join(", "),
211 )
212 }
213
214 static INJECT_RE: LazyLock<Regex> =
216 LazyLock::new(|| Regex::new("// INJECT:[A-Za-z0-9_]+").unwrap());
217 let mut matches = INJECT_RE.find_iter(&content).peekable();
218
219 if matches.peek().is_some() {
220 bail!(
221 "Invariant: Expected to inject all injections, found {}",
222 matches.map(|m| m.as_str()).collect::<Vec<_>>().join(", "),
223 )
224 }
225
226 if !missing_injections.is_empty() {
228 bail!(
229 "Invariant: Expected to inject all injections, missing {} in template",
230 missing_injections.join(", "),
231 )
232 }
233
234 let mut missing_imports = Vec::new();
236 for (key, import_path) in imports {
237 let mut full = format!("// OPTIONAL_IMPORT:{key}");
238 let namespace = if !content.contains(&full) {
239 full = format!("// OPTIONAL_IMPORT:* as {key}");
240 if content.contains(&full) {
241 true
242 } else {
243 missing_imports.push(key);
244 continue;
245 }
246 } else {
247 false
248 };
249
250 if let Some(path) = import_path {
251 content = content.replace(
252 &full,
253 &format!(
254 "import {}{} from {}",
255 if namespace { "* as " } else { "" },
256 key,
257 serde_json::to_string(&path).unwrap(),
258 ),
259 );
260 } else {
261 content = content.replace(&full, &format!("const {key} = null"));
262 }
263 }
264
265 static OPTIONAL_IMPORT_RE: LazyLock<Regex> =
267 LazyLock::new(|| Regex::new("// OPTIONAL_IMPORT:(\\* as )?[A-Za-z0-9_]+").unwrap());
268 let mut matches = OPTIONAL_IMPORT_RE.find_iter(&content).peekable();
269
270 if matches.peek().is_some() {
271 bail!(
272 "Invariant: Expected to inject all imports, found {}",
273 matches.map(|m| m.as_str()).collect::<Vec<_>>().join(", "),
274 )
275 }
276
277 if !missing_imports.is_empty() {
279 bail!(
280 "Invariant: Expected to inject all imports, missing {} in template",
281 missing_imports.join(", "),
282 )
283 }
284
285 if !content.ends_with('\n') {
287 content.push('\n');
288 }
289
290 Ok(content)
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn test_expand_next_js_template() {
299 let input = r#"
300 import '../../foo/bar';
301 import * as userlandPage from 'VAR_USERLAND'
302 // OPTIONAL_IMPORT:* as userland500Page
303 // OPTIONAL_IMPORT:incrementalCacheHandler
304 // INJECT_RAW:extraImports
305
306 // INJECT:nextConfig
307 const srcPage = 'VAR_PAGE'
308 "#;
309
310 let expected = r#"
311 import "next/src/foo/bar";
312 import * as userlandPage from "INNER_PAGE_ENTRY"
313 import * as userland500Page from "INNER_ERROR_500"
314 const incrementalCacheHandler = null
315 import handlerX from "INNER_HANDLER"
316
317 const nextConfig = {}
318 const srcPage = "./some/path.js"
319 "#;
320
321 let output = expand_next_js_template(
322 input,
323 "project/node_modules/next/src/build/templates/test-case.js",
324 "project/node_modules/next",
325 [
326 ("VAR_USERLAND", "INNER_PAGE_ENTRY"),
327 ("VAR_PAGE", "./some/path.js"),
328 ],
329 [
330 ("nextConfig", "{}"),
331 ("extraImports", r#"import handlerX from "INNER_HANDLER""#),
332 ],
333 [
334 ("incrementalCacheHandler", None),
335 ("userland500Page", Some("INNER_ERROR_500")),
336 ],
337 )
338 .unwrap();
339 println!("{output}");
340
341 assert_eq!(output.trim_end(), expected.trim_end());
342 }
343}