Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[UXIT-1731] Validate Image Src #919

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
2 changes: 1 addition & 1 deletion src/app/(homepage)/components/FeaturedBlogPosts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function FeaturedBlogPosts() {
text: 'Learn More',
}}
image={{
...(image || graphicsData.imageFallback.data),
...image,
alt: '',
objectFit: 'cover',
sizes: buildImageSizeProp({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function FeaturedEcosystemProjects({
icon: MagnifyingGlass,
}}
image={{
...(image || graphicsData.imageFallback.data),
...image,
alt: '',
objectFit: 'contain',
padding: Boolean(image),
Expand Down
9 changes: 3 additions & 6 deletions src/app/_components/ArticleHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import Image from 'next/image'

import type { ImageProps } from '@/types/imageType'

import { buildImageSizeProp } from '@/utils/buildImageSizeProp'

import { type HeadingProps, Heading } from '@/components/Heading'
import { SmartImage, type SmartImageProps } from '@/components/SmartImage'

type ArticleHeaderProps = {
image: ImageProps
image: SmartImageProps
children?: React.ReactNode
}

Expand All @@ -20,7 +17,7 @@ export function ArticleHeader({ image, children }: ArticleHeaderProps) {
<header className="space-y-6">
<div className="space-y-6">{children}</div>
<div className="relative aspect-video">
<Image
<SmartImage
fill
priority
quality={100}
Expand Down
19 changes: 6 additions & 13 deletions src/app/_components/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import Image, { type ImageProps } from 'next/image'
import Image, { type StaticImageData } from 'next/image'

import { ArrowUpRight } from '@phosphor-icons/react/dist/ssr'
import { clsx } from 'clsx'
import theme from 'tailwindcss/defaultTheme'

import { type CTAProps } from '@/types/ctaType'
import type { ImageObjectFit, StaticImageProps } from '@/types/imageType'

import { buildImageSizeProp } from '@/utils/buildImageSizeProp'
import { isExternalLink } from '@/utils/linkUtils'
Expand All @@ -15,25 +14,19 @@ import { BaseLink } from '@/components/BaseLink'
import { Heading } from '@/components/Heading'
import { Icon } from '@/components/Icon'
import { Meta, type MetaDataType } from '@/components/Meta'
import { SmartImage, type SmartImageProps } from '@/components/SmartImage'
import {
type TagGroupProps,
TagGroup,
} from '@/components/TagComponents/TagGroup'

type CardImageProps = (StaticImageProps | ImageProps) & {
objectFit?: ImageObjectFit
padding?: boolean
priority?: boolean
sizes?: string
}

type CardProps = {
title: string | React.ReactNode
tags?: TagGroupProps['tags']
metaData?: MetaDataType
description?: string
cta?: CTAPropsWithSpacing
image?: CardImageProps
image?: SmartImageProps
borderColor?: 'brand-300' | 'brand-400' | 'brand-500' | 'brand-600'
textIsClamped?: boolean
as?: React.ElementType
Expand Down Expand Up @@ -102,7 +95,7 @@ Card.Image = function ImageComponent({
const isStaticImage = 'data' in image

const commonProps = {
alt: image.alt,
alt: image.alt || '',
priority: image.priority,
quality: 100,
sizes:
Expand All @@ -120,15 +113,15 @@ Card.Image = function ImageComponent({
<Image
{...commonProps}
className={clsx(commonProps.className, 'aspect-video')}
src={image.data}
src={image.data as StaticImageData}
alt={commonProps.alt}
/>
)
}

return (
<div className="relative aspect-video">
<Image
<SmartImage
fill
{...commonProps}
className={clsx(commonProps.className, 'h-full w-full')}
Expand Down
17 changes: 6 additions & 11 deletions src/app/_components/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import Image, { type ImageProps } from 'next/image'
import Image, { type StaticImageData } from 'next/image'

import { clsx } from 'clsx'

import type { ImageObjectFit, StaticImageProps } from '@/types/imageType'

import { buildImageSizeProp } from '@/utils/buildImageSizeProp'

import {
Expand All @@ -17,18 +15,15 @@ import {
import { Heading } from '@/components/Heading'
import { Meta, type MetaDataType } from '@/components/Meta'
import { SectionDivider } from '@/components/SectionDivider'
import { SmartImage, type SmartImageProps } from '@/components/SmartImage'

type TitleProps = {
children: string
}

type PageHeaderImageProps = (StaticImageProps | ImageProps) & {
objectFit?: ImageObjectFit
}

type PageHeaderProps = {
title: TitleProps['children']
image: PageHeaderImageProps
image: SmartImageProps
isFeatured?: boolean
metaData?: MetaDataType
description?: DescriptionTextType
Expand Down Expand Up @@ -77,7 +72,7 @@ PageHeader.Image = function PageHeaderImage({
const isStaticImage = 'data' in image

const commonProps = {
alt: image.alt,
alt: image.alt || '',
priority: true,
quality: 100,
sizes: buildImageSizeProp({ startSize: '100vw', lg: '490px' }),
Expand All @@ -93,15 +88,15 @@ PageHeader.Image = function PageHeaderImage({
<Image
{...commonProps}
className={clsx(commonProps.className, 'aspect-video')}
src={image.data}
src={image.data as StaticImageData}
alt={commonProps.alt}
/>
)
}

return (
<div className="relative aspect-video">
<Image
<SmartImage
fill
{...commonProps}
className={clsx(commonProps.className, 'h-full w-full')}
Expand Down
70 changes: 70 additions & 0 deletions src/app/_components/SmartImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use server'
Copy link
Collaborator Author

@barbaraperic barbaraperic Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to be explicit to run on the server because fs and path exclusively run in the node env

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't components 'use server' by default?

I haven't seen this used in components. What does it do?

Copy link
Collaborator Author

@barbaraperic barbaraperic Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically makes sure it runs server side. For example, in GovernanceCalendarCard (use client component) we import Card component which uses SmartImage. if we don't explicitly write use server in the SmartImage component, it will run on the client, and in that case we get an error because we use fs which can't run in the browser.


import fs from 'fs/promises'
import path from 'path'

import Image, { type ImageProps } from 'next/image'

import type { ImageObjectFit, StaticImageProps } from '@/types/imageType'

import { graphicsData } from '@/data/graphicsData'

const { data: fallbackSrc, alt: fallbackAlt } = graphicsData.imageFallback

export type SmartImageProps = {
src?: string | StaticImageProps['data']
alt?: string
className?: string
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

className?: string can be included in Omit<ImageProps, 'src' | 'alt'>

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm it's included in the line 21? Or you're referring to something else?

objectFit?: ImageObjectFit
padding?: boolean
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does padding do?

Copy link
Collaborator Author

@barbaraperic barbaraperic Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we use it in the Card component, same as objectFit

} & Omit<ImageProps, 'src' | 'alt' | 'className'>

export async function SmartImage({ src, alt = '', ...props }: SmartImageProps) {
const imageSrc = await getImageSrc(src)

const imageExists = imageSrc !== fallbackSrc

return (
<Image src={imageSrc} alt={imageExists ? alt : fallbackAlt} {...props} />
)
}

async function getImageSrc(src: SmartImageProps['src']) {
if (!src) {
return fallbackSrc
}
const isAssetImage = typeof src === 'string' && src.startsWith('/assets')
const isRemoteImage =
typeof src === 'string' && (src.startsWith('http') || src.startsWith('//'))

if (isAssetImage) {
const assetExists = await checkAssetImageExists(src)
return assetExists ? src : fallbackSrc
}

if (isRemoteImage) {
const remoteImageExists = await checkRemoteImageExists(src)
return remoteImageExists ? src : fallbackSrc
}

return src
}

async function checkRemoteImageExists(url: string) {
try {
const response = await fetch(url, { method: 'HEAD' })
return response.ok
} catch {
return false
}
}

async function checkAssetImageExists(src: string) {
const publicPath = path.join(process.cwd(), 'public', src)
try {
await fs.access(publicPath, fs.constants.F_OK)
return true
} catch {
return false
}
}
Comment on lines +62 to +70
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads a bit better maybe?

Suggested change
async function checkAssetImageExists(src: string) {
const publicPath = path.join(process.cwd(), 'public', src)
try {
await fs.access(publicPath, fs.constants.F_OK)
return true
} catch {
return false
}
}
async function checkAssetImageExists(src: string) {
const publicPath = path.join(process.cwd(), 'public', src)
const stats = await fs.stat(publicPath)
return stats.isFile()
}

Copy link
Collaborator Author

@barbaraperic barbaraperic Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, I like how async await reads, but this made me think we could use try catch to capture errors? what do you think?

async function checkAssetImageExists(src: string) {
  const publicPath = path.join(process.cwd(), 'public', src);
  try {
    await fs.access(publicPath, fs.constants.F_OK);
    return true;
  } catch (error) {
    console.error(`Error checking asset image existence at path "${publicPath}":`, error);
    return false;
  }
}

2 changes: 1 addition & 1 deletion src/app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export default function About() {
description={description}
image={
image && {
...(image || graphicsData.imageFallback.data),
...image,
alt: '',
sizes: buildImageSizeProp({
startSize: '100vw',
Expand Down
2 changes: 1 addition & 1 deletion src/app/blog/[slug]/components/BlogPostHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function BlogPostHeader({
return (
<ArticleHeader
image={{
src: image?.src || graphicsData.imageFallback.data.src,
src: image?.src,
alt: '',
}}
>
Expand Down
4 changes: 2 additions & 2 deletions src/app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export default function Blog({ searchParams }: Props) {
description={featuredPost.description}
metaData={getMetaData(featuredPost.publishedOn)}
image={{
...(featuredPost.image || graphicsData.imageFallback.data),
...featuredPost.image,
alt: '',
objectFit: 'cover',
}}
Expand Down Expand Up @@ -200,7 +200,7 @@ export default function Blog({ searchParams }: Props) {
icon: BookOpen,
}}
image={{
...(image || graphicsData.imageFallback.data),
...image,
alt: '',
priority: isFirstTwoImages,
objectFit: 'cover',
Expand Down
4 changes: 1 addition & 3 deletions src/app/digest/[slug]/components/DigestArticleHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { graphicsData } from '@/data/graphicsData'

import { ArticleHeader } from '@/components/ArticleHeader'
import { AvatarGroup } from '@/components/AvatarGroup'
import { TagGroup } from '@/components/TagComponents/TagGroup'
Expand All @@ -21,7 +19,7 @@ export function DigestArticleHeader({
return (
<ArticleHeader
image={{
src: image?.src || graphicsData.imageFallback.data.src,
src: image?.src,
alt: '',
}}
>
Expand Down
2 changes: 1 addition & 1 deletion src/app/digest/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default function Digest() {
icon: BookOpen,
}}
image={{
...(image || graphicsData.imageFallback.data),
...image,
alt: image?.alt || '',
sizes: buildImageSizeProp({
startSize: '100vw',
Expand Down
14 changes: 4 additions & 10 deletions src/app/ecosystem-explorer/[slug]/components/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
import Image, { type ImageProps } from 'next/image'

import { clsx } from 'clsx'

import { graphicsData } from '@/data/graphicsData'

import { buildImageSizeProp } from '@/utils/buildImageSizeProp'

type ImagePropsWithOptionalAlt = Omit<ImageProps, 'alt'> & {
alt?: string
}
import { SmartImage, type SmartImageProps } from '@/components/SmartImage'

type PageHeaderProps = {
image?: ImagePropsWithOptionalAlt
image?: SmartImageProps
}

export function PageHeader({ image }: PageHeaderProps) {
return (
<header className="space-y-10 md:space-y-16">
<div className="relative h-48 md:w-3/4 lg:w-2/3 xl:w-3/5">
<Image
<SmartImage
fill
priority
src={image?.src || graphicsData.imageFallback.data.src}
src={image?.src}
alt={''}
className={clsx(
image?.src && 'rounded-lg',
Expand Down
2 changes: 1 addition & 1 deletion src/app/ecosystem-explorer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export default function EcosystemExplorer({ searchParams }: Props) {
icon: BookOpen,
}}
image={{
...(image || graphicsData.imageFallback.data),
...image,
alt: '',
objectFit: 'contain',
padding: Boolean(image),
Expand Down
2 changes: 1 addition & 1 deletion src/app/events/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export default function EventEntry({ params }: EventProps) {
eventHasConcluded,
})}
image={{
...(image || graphicsData.imageFallback.data),
...image,
alt: '',
objectFit: 'cover',
}}
Expand Down
4 changes: 2 additions & 2 deletions src/app/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export default function Events({ searchParams }: Props) {
location: featuredEvent.location.primary,
})}
image={{
...(featuredEvent.image || graphicsData.imageFallback.data),
...featuredEvent.image,
alt: '',
objectFit: 'cover',
}}
Expand Down Expand Up @@ -210,7 +210,7 @@ export default function Events({ searchParams }: Props) {
location: location.primary,
})}
image={{
...(image || graphicsData.imageFallback.data),
...image,
alt: '',
priority: isFirstTwoImages,
objectFit: 'cover',
Expand Down
2 changes: 1 addition & 1 deletion src/app/grants/components/FeaturedGrantGraduates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function FeaturedGrantGraduates({
icon: BookOpen,
}}
image={{
...(image || graphicsData.imageFallback.data),
...image,
alt: '',
objectFit: 'contain',
padding: Boolean(image),
Expand Down
Loading