next_core/next_app/metadata/
mod.rs

1use std::ops::Deref;
2
3use anyhow::Result;
4use once_cell::sync::Lazy;
5use rustc_hash::FxHashMap;
6use turbo_rcstr::RcStr;
7use turbo_tasks_fs::FileSystemPath;
8
9use crate::next_app::{AppPage, PageSegment, PageType};
10
11pub mod image;
12pub mod route;
13
14pub static STATIC_LOCAL_METADATA: Lazy<FxHashMap<&'static str, &'static [&'static str]>> =
15    Lazy::new(|| {
16        FxHashMap::from_iter([
17            (
18                "icon",
19                &["ico", "jpg", "jpeg", "png", "svg"] as &'static [&'static str],
20            ),
21            ("apple-icon", &["jpg", "jpeg", "png"]),
22            ("opengraph-image", &["jpg", "jpeg", "png", "gif"]),
23            ("twitter-image", &["jpg", "jpeg", "png", "gif"]),
24            ("sitemap", &["xml"]),
25        ])
26    });
27
28pub static STATIC_GLOBAL_METADATA: Lazy<FxHashMap<&'static str, &'static [&'static str]>> =
29    Lazy::new(|| {
30        FxHashMap::from_iter([
31            ("favicon", &["ico"] as &'static [&'static str]),
32            ("manifest", &["webmanifest", "json"]),
33            ("robots", &["txt"]),
34        ])
35    });
36
37pub struct MetadataFileMatch<'a> {
38    pub metadata_type: &'a str,
39    pub number: Option<u32>,
40    pub dynamic: bool,
41}
42
43fn match_numbered_metadata(stem: &str) -> Option<(&str, &str)> {
44    let (_whole, stem, number) = lazy_regex::regex_captures!(
45        "^(icon|apple-icon|opengraph-image|twitter-image)(\\d+)$",
46        stem
47    )?;
48
49    Some((stem, number))
50}
51
52fn match_metadata_file<'a>(
53    filename: &'a str,
54    page_extensions: &[RcStr],
55    metadata: &FxHashMap<&str, &[&str]>,
56) -> Option<MetadataFileMatch<'a>> {
57    let (stem, ext) = filename.split_once('.')?;
58
59    let (stem, number) = match match_numbered_metadata(stem) {
60        Some((stem, number)) => {
61            let number: u32 = number.parse().ok()?;
62            (stem, Some(number))
63        }
64        _ => (stem, None),
65    };
66
67    let exts = metadata.get(stem)?;
68
69    // favicon can't be dynamic
70    if stem != "favicon" && page_extensions.iter().any(|e| e == ext) {
71        return Some(MetadataFileMatch {
72            metadata_type: stem,
73            number,
74            dynamic: true,
75        });
76    }
77
78    exts.contains(&ext).then_some(MetadataFileMatch {
79        metadata_type: stem,
80        number,
81        dynamic: false,
82    })
83}
84
85pub(crate) async fn get_content_type(path: FileSystemPath) -> Result<String> {
86    let stem = path.file_stem();
87    let mut ext = path.extension();
88
89    let name = stem.unwrap_or_default();
90    if ext == "jpg" {
91        ext = "jpeg"
92    }
93
94    if name == "favicon" && ext == "ico" {
95        return Ok("image/x-icon".to_string());
96    }
97    if name == "sitemap" {
98        return Ok("application/xml".to_string());
99    }
100    if name == "robots" {
101        return Ok("text/plain".to_string());
102    }
103    if name == "manifest" {
104        return Ok("application/manifest+json".to_string());
105    }
106
107    if ext == "png" || ext == "jpeg" || ext == "ico" || ext == "svg" {
108        return Ok(mime_guess::from_ext(ext)
109            .first_or_octet_stream()
110            .to_string());
111    }
112
113    Ok("text/plain".to_string())
114}
115
116pub fn match_local_metadata_file<'a>(
117    basename: &'a str,
118    page_extensions: &[RcStr],
119) -> Option<MetadataFileMatch<'a>> {
120    match_metadata_file(basename, page_extensions, STATIC_LOCAL_METADATA.deref())
121}
122
123pub struct GlobalMetadataFileMatch<'a> {
124    pub metadata_type: &'a str,
125    pub dynamic: bool,
126}
127
128pub fn match_global_metadata_file<'a>(
129    basename: &'a str,
130    page_extensions: &[RcStr],
131) -> Option<GlobalMetadataFileMatch<'a>> {
132    match_metadata_file(basename, page_extensions, STATIC_GLOBAL_METADATA.deref()).map(|m| {
133        GlobalMetadataFileMatch {
134            metadata_type: m.metadata_type,
135            dynamic: m.dynamic,
136        }
137    })
138}
139
140fn split_directory(path: &str) -> (Option<&str>, &str) {
141    if let Some((dir, basename)) = path.rsplit_once('/') {
142        if dir.is_empty() {
143            return (Some("/"), basename);
144        }
145
146        (Some(dir), basename)
147    } else {
148        (None, path)
149    }
150}
151
152fn filename(path: &str) -> &str {
153    split_directory(path).1
154}
155
156pub(crate) fn split_extension(path: &str) -> (&str, Option<&str>) {
157    let filename = filename(path);
158    if let Some((filename_before_extension, ext)) = filename.rsplit_once('.') {
159        if filename_before_extension.is_empty() {
160            return (filename, None);
161        }
162
163        (filename_before_extension, Some(ext))
164    } else {
165        (filename, None)
166    }
167}
168
169fn file_stem(path: &str) -> &str {
170    split_extension(path).0
171}
172
173/// When you only pass the file extension as `[]`, it will only match the static
174/// convention files e.g. `/robots.txt`, `/sitemap.xml`, `/favicon.ico`,
175/// `/manifest.json`.
176///
177/// When you pass the file extension as `['js', 'jsx', 'ts',
178/// 'tsx']`, it will also match the dynamic convention files e.g. /robots.js,
179/// /sitemap.tsx, /favicon.jsx, /manifest.ts.
180///
181/// When `withExtension` is false, it will match the static convention files
182/// without the extension, by default it's true e.g. /robots, /sitemap,
183/// /favicon, /manifest, use to match dynamic API routes like app/robots.ts.
184pub fn is_metadata_route_file(
185    app_dir_relative_path: &str,
186    page_extensions: &[RcStr],
187    with_extension: bool,
188) -> bool {
189    let (dir, filename) = split_directory(app_dir_relative_path);
190
191    if with_extension {
192        if match_local_metadata_file(filename, page_extensions).is_some() {
193            return true;
194        }
195    } else {
196        let stem = file_stem(filename);
197        let stem = match_numbered_metadata(stem)
198            .map(|(stem, _)| stem)
199            .unwrap_or(stem);
200
201        if STATIC_LOCAL_METADATA.contains_key(stem) {
202            return true;
203        }
204    }
205
206    if dir != Some("/") {
207        return false;
208    }
209
210    if with_extension {
211        if match_global_metadata_file(filename, page_extensions).is_some() {
212            return true;
213        }
214    } else {
215        let base_name = file_stem(filename);
216        if STATIC_GLOBAL_METADATA.contains_key(base_name) {
217            return true;
218        }
219    }
220
221    false
222}
223
224pub fn is_static_metadata_route_file(app_dir_relative_path: &str) -> bool {
225    is_metadata_route_file(app_dir_relative_path, &[], true)
226}
227
228/// Remove the 'app' prefix or '/route' suffix, only check the route name since
229/// they're only allowed in root app directory
230///
231/// e.g.
232/// - /app/robots -> /robots
233/// - app/robots -> /robots
234/// - /robots -> /robots
235pub fn is_metadata_route(mut route: &str) -> bool {
236    if let Some(stripped) = route.strip_prefix("/app/") {
237        route = stripped;
238    } else if let Some(stripped) = route.strip_prefix("app/") {
239        route = stripped;
240    }
241
242    if let Some(stripped) = route.strip_suffix("/route") {
243        route = stripped;
244    }
245
246    let mut page = route.to_string();
247    if !page.starts_with('/') {
248        page = format!("/{page}");
249    }
250
251    !page.ends_with("/page") && is_metadata_route_file(&page, &[], false)
252}
253
254/// djb_2 hash implementation referenced from [here](http://www.cse.yorku.ca/~oz/hash.html)
255fn djb2_hash(str: &str) -> u32 {
256    str.chars().fold(5381, |hash, c| {
257        ((hash << 5).wrapping_add(hash)).wrapping_add(c as u32) // hash * 33 + c
258    })
259}
260
261// this is here to mirror next.js behaviour (`toString(36).slice(0, 6)`)
262fn format_radix(mut x: u32, radix: u32) -> String {
263    let mut result = vec![];
264
265    loop {
266        let m = x % radix;
267        x /= radix;
268
269        // will panic if you use a bad radix (< 2 or > 36).
270        result.push(std::char::from_digit(m, radix).unwrap());
271        if x == 0 {
272            break;
273        }
274    }
275
276    result.reverse();
277
278    // We only need the first 6 characters of the hash but sometimes the hash is too short.
279    // In JavaScript, we use `toString(36).slice(0, 6)` to get the first 6 characters of the hash,
280    // but it will automatically take the minimum of the length of the hash and 6. Rust will panic.
281    let len = result.len().min(6);
282    result[..len].iter().collect()
283}
284
285/// If there's special convention like (...) or @ in the page path,
286/// Give it a unique hash suffix to avoid conflicts
287///
288/// e.g.
289/// /opengraph-image -> /opengraph-image
290/// /(post)/opengraph-image.tsx -> /opengraph-image-[0-9a-z]{6}
291///
292/// Sitemap is an exception, it should not have a suffix.
293/// As the generated urls are for indexer and usually one sitemap contains all the urls of the sub
294/// routes. The sitemap should be unique in each level and not have a suffix.
295///
296/// /sitemap -> /sitemap
297/// /(post)/sitemap -> /sitemap
298fn get_metadata_route_suffix(page: &str) -> Option<String> {
299    // skip sitemap
300    if page.ends_with("/sitemap") {
301        return None;
302    }
303
304    // Get the parent pathname of the page
305    let parent_pathname = split_directory(page).0.unwrap_or_default();
306    let segments = parent_pathname.split('/').collect::<Vec<&str>>();
307
308    // if any segment is group or parallel route segment, we should add a suffix.
309    if segments.iter().any(|segment| {
310        segment.starts_with('(') && segment.ends_with(')')
311            || segment.starts_with('@') && *segment != "@children"
312    }) {
313        Some(format_radix(djb2_hash(parent_pathname), 36))
314    } else {
315        None
316    }
317}
318
319/// Map metadata page key to the corresponding route
320///
321/// static file page key:    /app/robots.txt -> /robots.txt -> /robots.txt/route
322/// dynamic route page key:  /app/robots.tsx -> /robots -> /robots.txt/route
323pub fn normalize_metadata_route(mut page: AppPage) -> Result<AppPage> {
324    if !is_metadata_route(&format!("{page}")) {
325        return Ok(page);
326    }
327
328    let mut route = page.to_string();
329    let mut suffix: Option<String> = None;
330    if route == "/robots" {
331        route += ".txt"
332    } else if route == "/manifest" {
333        route += ".webmanifest"
334    } else {
335        suffix = get_metadata_route_suffix(&route);
336    }
337
338    // Support both /<metadata-route.ext> and custom routes
339    // /<metadata-route>/route.ts. If it's a metadata file route, we need to
340    // append /[id]/route to the page.
341    if !route.ends_with("/route") {
342        let (base_name, ext) = split_extension(&route);
343
344        page.0.pop();
345
346        page.push(PageSegment::Static(
347            format!(
348                "{}{}{}",
349                base_name,
350                suffix
351                    .map(|suffix| format!("-{suffix}"))
352                    .unwrap_or_default(),
353                ext.map(|ext| format!(".{ext}")).unwrap_or_default(),
354            )
355            .into(),
356        ))?;
357
358        page.push(PageSegment::PageType(PageType::Route))?;
359    }
360
361    Ok(page)
362}
363
364#[cfg(test)]
365mod test {
366    use super::{djb2_hash, format_radix, normalize_metadata_route};
367    use crate::next_app::AppPage;
368
369    #[test]
370    fn test_normalize_metadata_route() {
371        let cases = vec![
372            [
373                "/client/(meme)/more-route/twitter-image",
374                "/client/(meme)/more-route/twitter-image-769mad/route",
375            ],
376            [
377                "/client/(meme)/more-route/twitter-image2",
378                "/client/(meme)/more-route/twitter-image2-769mad/route",
379            ],
380            ["/robots.txt", "/robots.txt/route"],
381            ["/manifest.webmanifest", "/manifest.webmanifest/route"],
382        ];
383
384        for [input, expected] in cases {
385            let page = AppPage::parse(input).unwrap();
386            let normalized = normalize_metadata_route(page).unwrap();
387
388            assert_eq!(&normalized.to_string(), expected);
389        }
390    }
391
392    #[test]
393    fn test_format_radix_doesnt_panic_with_result_less_than_6_characters() {
394        let hash = format_radix(djb2_hash("/lookup/[domain]/(dns)"), 36);
395        assert!(hash.len() < 6);
396    }
397}