next_core/next_app/metadata/
mod.rs1use 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 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
175pub 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
230pub 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
256fn djb2_hash(str: &str) -> u32 {
258 str.chars().fold(5381, |hash, c| {
259 ((hash << 5).wrapping_add(hash)).wrapping_add(c as u32) })
261}
262
263fn 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 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
282fn get_metadata_route_suffix(page: &str) -> Option<String> {
296 if page.ends_with("/sitemap") {
298 return None;
299 }
300
301 let parent_pathname = split_directory(page).0.unwrap_or_default();
303 let segments = parent_pathname.split('/').collect::<Vec<&str>>();
304
305 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
316pub 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 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}