next_taskless/
lib.rs

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
14/// Given a next.js template file's contents, replaces `replacements` and `injections` and makes
15/// sure there are none left over.
16///
17/// See `packages/next/src/build/templates/` for examples.
18///
19/// Paths should be unix or node.js-style paths where `/` is used as the path separator. They should
20/// not be windows-style paths.
21pub 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    /// See [regex::Regex::replace_all].
35    fn replace_all<E>(
36        re: &regex::Regex,
37        haystack: &str,
38        mut replacement: impl FnMut(&regex::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    // Update the relative imports to be absolute. This will update any relative imports to be
53    // relative to the root of the `next` package.
54    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    // Verify that at least one import was replaced. It's the case today where every template file
96    // has at least one import to update, so this ensures that we don't accidentally remove the
97    // import replacement code or use the wrong template file.
98    if count == 0 {
99        bail!("Invariant: Expected to replace at least one import")
100    }
101
102    // Replace all the template variables with the actual values. If a template variable is missing,
103    // throw an error.
104    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    // Check to see if there's any remaining template variables.
116    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    // Check to see if any template variable was provided but not used.
127    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    // Replace the injections.
135    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    // Check to see if there's any remaining injections.
147    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    // Check to see if any injection was provided but not used.
159    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    // Replace the optional imports.
167    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    // Check to see if there's any remaining imports.
198    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    // Check to see if any import was provided but not used.
210    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    // Ensure that the last line is a newline.
218    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}