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
44#[turbo_tasks::function]
45pub async fn dynamic_image_metadata_source(
46    asset_context: Vc<Box<dyn AssetContext>>,
47    path: FileSystemPath,
48    ty: RcStr,
49    page: AppPage,
50) -> Result<Vc<Box<dyn Source>>> {
51    let stem = path.file_stem();
52    let stem = stem.unwrap_or_default();
53    let ext = path.extension();
54
55    let hash_query = format!("?{:x}", hash_file_content(path.clone()).await?);
56
57    let use_numeric_sizes = ty == "twitter" || ty == "openGraph";
58    let sizes = if use_numeric_sizes {
59        "data.width = size.width; data.height = size.height;".to_string()
60    } else {
61        // Note: This case seemingly can never happen because this code runs for dynamic metadata
62        // which has e.g. a `.js` or `.ts` extension not `.svg`. Branching code is still here to
63        // match the static implementation
64        //
65        // For SVGs, skip sizes and use "any" to let it scale automatically based on viewport,
66        // For the images doesn't provide the size properly, use "any" as well.
67        // If the size is presented, use the actual size for the image.
68        let sizes = if ext == "svg" {
69            "any"
70        } else {
71            "${size.width}x${size.height}"
72        };
73
74        format!("data.sizes = `{sizes}`;")
75    };
76
77    let source = Vc::upcast(FileSource::new(path.clone()));
78    let module = asset_context
79        .process(
80            source,
81            ReferenceType::EcmaScriptModules(EcmaScriptModulesReferenceSubType::Undefined),
82        )
83        .module();
84    let exports = &*collect_direct_exports(module).await?;
85    let exported_fields_excluding_default = exports
86        .iter()
87        .filter(|e| *e != "default")
88        .cloned()
89        .collect::<Vec<_>>()
90        .join(", ");
91
92    let code = formatdoc! {
93        r#"
94            import {{ {exported_fields_excluding_default} }} from {resource_path}
95            import {{ fillMetadataSegment }} from 'next/dist/lib/metadata/get-metadata-route'
96
97            const imageModule = {{ {exported_fields_excluding_default} }}
98
99            export default async function (props) {{
100                const {{ __metadata_id__: _, ...params }} = await props.params
101                const imageUrl = fillMetadataSegment({pathname_prefix}, params, {page_segment})
102
103                const {{ generateImageMetadata }} = imageModule
104
105                function getImageMetadata(imageMetadata, idParam) {{
106                    const data = {{
107                        alt: imageMetadata.alt,
108                        type: imageMetadata.contentType || 'image/png',
109                        url: imageUrl + (idParam ? ('/' + idParam) : '') + {hash_query},
110                    }}
111                    const {{ size }} = imageMetadata
112                    if (size) {{
113                        {sizes}
114                    }}
115                    return data
116                }}
117
118                if (generateImageMetadata) {{
119                    const imageMetadataArray = await generateImageMetadata({{ params }})
120                    return imageMetadataArray.map((imageMetadata, index) => {{
121                        const idParam = (imageMetadata.id || index) + ''
122                        return getImageMetadata(imageMetadata, idParam)
123                    }})
124                }} else {{
125                    return [getImageMetadata(imageModule, '')]
126                }}
127            }}
128        "#,
129        exported_fields_excluding_default = exported_fields_excluding_default,
130        resource_path = StringifyJs(&format!("./{stem}.{ext}")),
131        pathname_prefix = StringifyJs(&page.to_string()),
132        page_segment = StringifyJs(stem),
133        sizes = sizes,
134        hash_query = StringifyJs(&hash_query),
135    };
136
137    let file = File::from(code);
138    let source = VirtualSource::new(
139        path.parent().join(&format!("{stem}--metadata.js"))?,
140        AssetContent::file(file.into()),
141    );
142
143    Ok(Vc::upcast(source))
144}
145
146#[turbo_tasks::function]
147async fn collect_direct_exports(module: Vc<Box<dyn Module>>) -> Result<Vc<Vec<RcStr>>> {
148    let Some(ecmascript_asset) =
149        Vc::try_resolve_sidecast::<Box<dyn EcmascriptChunkPlaceable>>(module).await?
150    else {
151        return Ok(Default::default());
152    };
153
154    if let EcmascriptExports::EsmExports(exports) = &*ecmascript_asset.get_exports().await? {
155        let exports = &*exports.await?;
156        return Ok(Vc::cell(exports.exports.keys().cloned().collect()));
157    }
158
159    Ok(Vc::cell(Vec::new()))
160}