next_core/next_app/metadata/
image.rs

1//! (partial) Rust port of the `next-metadata-image-loader`
2//!
3//! See `next/src/build/webpack/loaders/next-metadata-image-loader`
4
5use anyhow::{Result, bail};
6use indoc::formatdoc;
7use turbo_rcstr::RcStr;
8use turbo_tasks::Vc;
9use turbo_tasks_fs::{File, FileContent, FileSystemPath};
10use turbo_tasks_hash::hash_xxh3_hash64;
11use turbopack_core::{
12    asset::AssetContent,
13    context::AssetContext,
14    file_source::FileSource,
15    module::Module,
16    reference_type::{EcmaScriptModulesReferenceSubType, ReferenceType},
17    source::Source,
18    virtual_source::VirtualSource,
19};
20use turbopack_ecmascript::{
21    chunk::{EcmascriptChunkPlaceable, EcmascriptExports},
22    utils::StringifyJs,
23};
24
25use crate::next_app::AppPage;
26
27async fn hash_file_content(path: FileSystemPath) -> Result<u64> {
28    let original_file_content = path.read().await?;
29
30    Ok(match &*original_file_content {
31        FileContent::Content(content) => {
32            let content = content.content().to_bytes();
33            hash_xxh3_hash64(&*content)
34        }
35        FileContent::NotFound => {
36            bail!(
37                "metadata file not found: {}",
38                &path.value_to_string().await?
39            );
40        }
41    })
42}
43
44async fn dynamic_image_metadata_with_generator_source(
45    path: FileSystemPath,
46    ty: RcStr,
47    page: AppPage,
48    exported_fields_excluding_default: String,
49) -> Result<Vc<Box<dyn Source>>> {
50    let stem = path.file_stem();
51    let stem = stem.unwrap_or_default();
52    let ext = path.extension();
53
54    let hash_query = format!("?{:x}", hash_file_content(path.clone()).await?);
55
56    let use_numeric_sizes = ty == "twitter" || ty == "openGraph";
57    let sizes = if use_numeric_sizes {
58        "data.width = size.width; data.height = size.height;".to_string()
59    } else {
60        let sizes = if ext == "svg" {
61            "any"
62        } else {
63            "${size.width}x${size.height}"
64        };
65        format!("data.sizes = `{sizes}`;")
66    };
67
68    let code = formatdoc! {
69        r#"
70            import {{ {exported_fields_excluding_default} }} from {resource_path}
71            import {{ fillMetadataSegment }} from 'next/dist/lib/metadata/get-metadata-route'
72
73            const imageModule = {{ {exported_fields_excluding_default} }}
74
75            export default async function (props) {{
76                const {{ __metadata_id__: _, ...params }} = await props.params
77                const imageUrl = fillMetadataSegment({pathname_prefix}, params, {page_segment})
78
79                const {{ generateImageMetadata }} = imageModule
80
81                function getImageMetadata(imageMetadata, idParam) {{
82                    const data = {{
83                        alt: imageMetadata.alt,
84                        type: imageMetadata.contentType || 'image/png',
85                        url: imageUrl + (idParam ? ('/' + idParam) : '') + {hash_query},
86                    }}
87                    const {{ size }} = imageMetadata
88                    if (size) {{
89                        {sizes}
90                    }}
91                    return data
92                }}
93
94                const imageMetadataArray = await generateImageMetadata({{ params }})
95                return imageMetadataArray.map((imageMetadata, index) => {{
96                    const idParam = imageMetadata.id + ''
97                    return getImageMetadata(imageMetadata, idParam)
98                }})
99            }}
100        "#,
101        exported_fields_excluding_default = exported_fields_excluding_default,
102        resource_path = StringifyJs(&format!("./{stem}.{ext}")),
103        pathname_prefix = StringifyJs(&page.to_string()),
104        page_segment = StringifyJs(stem),
105        sizes = sizes,
106        hash_query = StringifyJs(&hash_query),
107    };
108
109    let file = File::from(code);
110    let source = VirtualSource::new(
111        path.parent().join(&format!("{stem}--metadata.js"))?,
112        AssetContent::file(file.into()),
113    );
114
115    Ok(Vc::upcast(source))
116}
117
118async fn dynamic_image_metadata_without_generator_source(
119    path: FileSystemPath,
120    ty: RcStr,
121    page: AppPage,
122    exported_fields_excluding_default: String,
123) -> Result<Vc<Box<dyn Source>>> {
124    let stem = path.file_stem();
125    let stem = stem.unwrap_or_default();
126    let ext = path.extension();
127
128    let hash_query = format!("?{:x}", hash_file_content(path.clone()).await?);
129
130    let use_numeric_sizes = ty == "twitter" || ty == "openGraph";
131    let sizes = if use_numeric_sizes {
132        "data.width = size.width; data.height = size.height;".to_string()
133    } else {
134        let sizes = if ext == "svg" {
135            "any"
136        } else {
137            "${size.width}x${size.height}"
138        };
139        format!("data.sizes = `{sizes}`;")
140    };
141
142    let code = formatdoc! {
143        r#"
144            import {{ {exported_fields_excluding_default} }} from {resource_path}
145            import {{ fillMetadataSegment }} from 'next/dist/lib/metadata/get-metadata-route'
146
147            const imageModule = {{ {exported_fields_excluding_default} }}
148
149            export default async function (props) {{
150                const {{ __metadata_id__: _, ...params }} = await props.params
151                const imageUrl = fillMetadataSegment({pathname_prefix}, params, {page_segment})
152
153                function getImageMetadata(imageMetadata, idParam) {{
154                    const data = {{
155                        alt: imageMetadata.alt,
156                        type: imageMetadata.contentType || 'image/png',
157                        url: imageUrl + (idParam ? ('/' + idParam) : '') + {hash_query},
158                    }}
159                    const {{ size }} = imageMetadata
160                    if (size) {{
161                        {sizes}
162                    }}
163                    return data
164                }}
165
166                return [getImageMetadata(imageModule, '')]
167            }}
168        "#,
169        exported_fields_excluding_default = exported_fields_excluding_default,
170        resource_path = StringifyJs(&format!("./{stem}.{ext}")),
171        pathname_prefix = StringifyJs(&page.to_string()),
172        page_segment = StringifyJs(stem),
173        sizes = sizes,
174        hash_query = StringifyJs(&hash_query),
175    };
176
177    let file = File::from(code);
178    let source = VirtualSource::new(
179        path.parent().join(&format!("{stem}--metadata.js"))?,
180        AssetContent::file(file.into()),
181    );
182
183    Ok(Vc::upcast(source))
184}
185
186#[turbo_tasks::function]
187pub async fn dynamic_image_metadata_source(
188    asset_context: Vc<Box<dyn AssetContext>>,
189    path: FileSystemPath,
190    ty: RcStr,
191    page: AppPage,
192) -> Result<Vc<Box<dyn Source>>> {
193    let source = Vc::upcast(FileSource::new(path.clone()));
194    let module = asset_context
195        .process(
196            source,
197            ReferenceType::EcmaScriptModules(EcmaScriptModulesReferenceSubType::Undefined),
198        )
199        .module();
200    let exports = &*collect_direct_exports(module).await?;
201    let exported_fields_excluding_default = exports
202        .iter()
203        .filter(|e| *e != "default")
204        .cloned()
205        .collect::<Vec<_>>()
206        .join(", ");
207
208    let has_generate_image_metadata = exports.contains(&"generateImageMetadata".into());
209
210    if has_generate_image_metadata {
211        dynamic_image_metadata_with_generator_source(
212            path,
213            ty,
214            page,
215            exported_fields_excluding_default,
216        )
217        .await
218    } else {
219        dynamic_image_metadata_without_generator_source(
220            path,
221            ty,
222            page,
223            exported_fields_excluding_default,
224        )
225        .await
226    }
227}
228
229#[turbo_tasks::function]
230async fn collect_direct_exports(module: Vc<Box<dyn Module>>) -> Result<Vc<Vec<RcStr>>> {
231    let Some(ecmascript_asset) =
232        Vc::try_resolve_sidecast::<Box<dyn EcmascriptChunkPlaceable>>(module).await?
233    else {
234        return Ok(Default::default());
235    };
236
237    if let EcmascriptExports::EsmExports(exports) = &*ecmascript_asset.get_exports().await? {
238        let exports = &*exports.await?;
239        return Ok(Vc::cell(exports.exports.keys().cloned().collect()));
240    }
241
242    Ok(Vc::cell(Vec::new()))
243}