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    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
40/// Same as [`expand_next_js_template`], but does not enforce that at least one relative
41/// import is present and rewritten. This is useful for very small templates that only
42/// use template variables/injections and have no imports of their own.
43pub 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    /// See [regex::Regex::replace_all].
77    fn replace_all<E>(
78        re: &regex::Regex,
79        haystack: &str,
80        mut replacement: impl FnMut(&regex::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    // Update the relative imports to be absolute. This will update any relative imports to be
95    // relative to the root of the `next` package.
96    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    // Verify that at least one import was replaced when required. It's the case today where every
138    // template file (except a few small internal helpers) has at least one import to update, so
139    // this ensures that we don't accidentally remove the import replacement code or use the wrong
140    // template file.
141    if require_import_replacement && count == 0 {
142        bail!("Invariant: Expected to replace at least one import")
143    }
144
145    // Replace all the template variables with the actual values. If a template variable is missing,
146    // throw an error.
147    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    // Check to see if there's any remaining template variables.
159    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    // Check to see if any template variable was provided but not used.
170    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    // Replace the injections.
178    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    // Check to see if there's any remaining injections.
190    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    // Check to see if any injection was provided but not used.
202    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    // Replace the optional imports.
210    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    // Check to see if there's any remaining imports.
241    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    // Check to see if any import was provided but not used.
253    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    // Ensure that the last line is a newline.
261    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}