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