next_taskless/
lib.rs

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
9/// Given a next.js template file's contents, replaces `replacements` and `injections` and makes
10/// sure there are none left over.
11///
12/// See `packages/next/src/build/templates/` for examples.
13///
14/// Paths should be unix or node.js-style paths where `/` is used as the path separator. They should
15/// not be windows-style paths.
16pub 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    /// See [regex::Regex::replace_all].
30    fn replace_all<E>(
31        re: &regex::Regex,
32        haystack: &str,
33        mut replacement: impl FnMut(&regex::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    // Update the relative imports to be absolute. This will update any relative imports to be
48    // relative to the root of the `next` package.
49    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    // Verify that at least one import was replaced. It's the case today where every template file
91    // has at least one import to update, so this ensures that we don't accidentally remove the
92    // import replacement code or use the wrong template file.
93    if count == 0 {
94        bail!("Invariant: Expected to replace at least one import")
95    }
96
97    // Replace all the template variables with the actual values. If a template variable is missing,
98    // throw an error.
99    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    // Check to see if there's any remaining template variables.
111    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    // Check to see if any template variable was provided but not used.
122    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    // Replace the injections.
130    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    // Check to see if there's any remaining injections.
142    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    // Check to see if any injection was provided but not used.
154    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    // Replace the optional imports.
162    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    // Check to see if there's any remaining imports.
193    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    // Check to see if any import was provided but not used.
205    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    // Ensure that the last line is a newline.
213    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}