Skip to main content

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> = 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    // Verify that at least one import was replaced when required. It's the case today where every
140    // template file (except a few small internal helpers) has at least one import to update, so
141    // this ensures that we don't accidentally remove the import replacement code or use the wrong
142    // template file.
143    if require_import_replacement && count == 0 {
144        bail!("Invariant: Expected to replace at least one import")
145    }
146
147    // Replace all the template variables with the actual values. If a template variable is missing,
148    // throw an error.
149    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    // Check to see if there's any remaining template variables.
161    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    // Check to see if any template variable was provided but not used.
172    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    // Replace the raw injections.
180    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    // Check to see if there's any remaining raw injections.
203    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    // Check to see if there's any remaining injections.
215    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    // Check to see if any injection was provided but not used.
227    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    // Replace the optional imports.
235    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    // Check to see if there's any remaining imports.
266    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    // Check to see if any import was provided but not used.
278    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    // Ensure that the last line is a newline.
286    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}