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_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 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
173pub 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
228pub 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
254fn djb2_hash(str: &str) -> u32 {
256 str.chars().fold(5381, |hash, c| {
257 ((hash << 5).wrapping_add(hash)).wrapping_add(c as u32) })
259}
260
261fn 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 result.push(std::char::from_digit(m, radix).unwrap());
271 if x == 0 {
272 break;
273 }
274 }
275
276 result.reverse();
277
278 let len = result.len().min(6);
282 result[..len].iter().collect()
283}
284
285fn get_metadata_route_suffix(page: &str) -> Option<String> {
299 if page.ends_with("/sitemap") {
301 return None;
302 }
303
304 let parent_pathname = split_directory(page).0.unwrap_or_default();
306 let segments = parent_pathname.split('/').collect::<Vec<&str>>();
307
308 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
319pub 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 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}