mirror of
https://github.com/supabase/supabase.git
synced 2026-09-22 13:37:53 +08:00
2ab1083b21
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix ## What is the current behavior? Social-media crawlers (X, LinkedIn, etc.) cache the dynamic OG images generated by the `generate-og` Edge Function. When an OG image is updated or a crawler caches the wrong image, the stale version persists indefinitely because the URL doesn't change between deploys. ## What is the new behavior? `getAbsoluteBlogSocialImage` now appends a `&v={timestamp}` parameter to OG image URLs that point to the `generate-og` Edge Function. The timestamp is evaluated once at build time (`Date.now()`), so each deploy produces unique meta tag URLs that bypass crawler caches. Static image URLs are left untouched. ## Additional context Only affects the `og:image` and `twitter:image` meta tags — on-site thumbnails and other image references are unchanged. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **Bug Fixes** * Fixed cache refresh behavior for dynamically generated OG images to ensure updated content is served with each deployment. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
160 lines
4.5 KiB
TypeScript
160 lines
4.5 KiB
TypeScript
import { SITE_ORIGIN } from './constants'
|
|
|
|
type BlogImageFields = {
|
|
imgSocial?: string
|
|
imgThumb?: string
|
|
}
|
|
|
|
type ValidationWarn = (message?: unknown, ...optionalParams: unknown[]) => void
|
|
|
|
const warnedBlogImageIssues = new Set<string>()
|
|
|
|
export const BLOG_PLACEHOLDER_IMAGE = '/images/blog/blog-placeholder.png'
|
|
export const BLOG_GRID_IMAGE_SIZES = '(max-width: 1023px) 100vw, (max-width: 1279px) 50vw, 33vw'
|
|
export const BLOG_FEATURED_IMAGE_SIZES = '(max-width: 1023px) 100vw, 42vw'
|
|
export const BLOG_POST_HERO_IMAGE_SIZES = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
|
|
|
|
function isAbsoluteUrl(image: string) {
|
|
return image.startsWith('http://') || image.startsWith('https://')
|
|
}
|
|
|
|
function warnOnce(key: string, message: string, warn: ValidationWarn) {
|
|
if (warnedBlogImageIssues.has(key)) return
|
|
|
|
warnedBlogImageIssues.add(key)
|
|
warn(message)
|
|
}
|
|
|
|
export function resolveBlogImagePath(image: string) {
|
|
if (isAbsoluteUrl(image) || image.startsWith('/')) {
|
|
return image
|
|
}
|
|
|
|
return `/images/blog/${image}`
|
|
}
|
|
|
|
export function toAbsoluteBlogImageUrl(image: string, siteOrigin: string = SITE_ORIGIN) {
|
|
const resolvedImage = resolveBlogImagePath(image)
|
|
|
|
if (isAbsoluteUrl(resolvedImage)) {
|
|
return resolvedImage
|
|
}
|
|
|
|
return `${siteOrigin}${resolvedImage}`
|
|
}
|
|
|
|
export function getBlogThumbnailImage(
|
|
{ imgThumb, imgSocial }: BlogImageFields,
|
|
options: { fallbackToPlaceholder?: boolean } = {}
|
|
) {
|
|
const image = imgThumb || imgSocial
|
|
|
|
if (!image) {
|
|
return options.fallbackToPlaceholder === false ? undefined : BLOG_PLACEHOLDER_IMAGE
|
|
}
|
|
|
|
return resolveBlogImagePath(image)
|
|
}
|
|
|
|
export function getBlogSocialImage({ imgThumb, imgSocial }: BlogImageFields) {
|
|
const image = imgSocial || imgThumb
|
|
return image ? resolveBlogImagePath(image) : undefined
|
|
}
|
|
|
|
/**
|
|
* Build-time ID appended to OG image URLs that point to the dynamic
|
|
* `generate-og` Edge Function. A fresh value on every deploy forces
|
|
* social-media crawlers (X, LinkedIn, etc.) to bypass their image cache.
|
|
*/
|
|
const buildId = Date.now()
|
|
|
|
export function getAbsoluteBlogSocialImage(
|
|
{ imgThumb, imgSocial }: BlogImageFields,
|
|
siteOrigin: string = SITE_ORIGIN
|
|
) {
|
|
const image = imgSocial || imgThumb
|
|
if (!image) return undefined
|
|
|
|
const url = toAbsoluteBlogImageUrl(image, siteOrigin)
|
|
|
|
// Only bust cache for the dynamic OG function, not static images
|
|
if (url.includes('/functions/v1/generate-og')) {
|
|
const separator = url.includes('?') ? '&' : '?'
|
|
return `${url}${separator}v=${buildId}`
|
|
}
|
|
|
|
return url
|
|
}
|
|
|
|
export function validateBlogFrontmatterImages(
|
|
frontmatter: BlogImageFields,
|
|
filePath: string,
|
|
warn: ValidationWarn = console.warn
|
|
) {
|
|
const hasImgSocial = typeof frontmatter.imgSocial !== 'undefined'
|
|
const hasImgThumb = typeof frontmatter.imgThumb !== 'undefined'
|
|
|
|
if (hasImgSocial && !hasImgThumb) {
|
|
warnOnce(
|
|
`${filePath}:imgThumb:missing`,
|
|
`[blog images] ${filePath}: missing "imgThumb". Adding it keeps on-site thumbnails separate from social previews.`,
|
|
warn
|
|
)
|
|
}
|
|
|
|
if (hasImgThumb && !hasImgSocial) {
|
|
warnOnce(
|
|
`${filePath}:imgSocial:missing`,
|
|
`[blog images] ${filePath}: missing "imgSocial". Adding it keeps social previews separate from on-site thumbnails.`,
|
|
warn
|
|
)
|
|
}
|
|
|
|
const imageFields = [
|
|
['imgSocial', frontmatter.imgSocial],
|
|
['imgThumb', frontmatter.imgThumb],
|
|
] as const
|
|
|
|
for (const [fieldName, imageValue] of imageFields) {
|
|
if (typeof imageValue === 'undefined') {
|
|
continue
|
|
}
|
|
|
|
if (typeof imageValue !== 'string') {
|
|
warnOnce(
|
|
`${filePath}:${fieldName}:invalid-type`,
|
|
`[blog images] ${filePath}: "${fieldName}" should be a string URL or a relative blog image path.`,
|
|
warn
|
|
)
|
|
continue
|
|
}
|
|
|
|
const trimmedValue = imageValue.trim()
|
|
|
|
if (!trimmedValue) {
|
|
warnOnce(
|
|
`${filePath}:${fieldName}:empty`,
|
|
`[blog images] ${filePath}: "${fieldName}" is empty. Remove it or provide a valid image path.`,
|
|
warn
|
|
)
|
|
continue
|
|
}
|
|
|
|
if (trimmedValue.startsWith('/images/blog/')) {
|
|
warnOnce(
|
|
`${filePath}:${fieldName}:prefixed`,
|
|
`[blog images] ${filePath}: "${fieldName}" should not include the "/images/blog/" prefix. Use a relative path like "my-post/og.png" instead.`,
|
|
warn
|
|
)
|
|
}
|
|
|
|
if (trimmedValue.startsWith('./') || trimmedValue.startsWith('../')) {
|
|
warnOnce(
|
|
`${filePath}:${fieldName}:relative-dot`,
|
|
`[blog images] ${filePath}: "${fieldName}" should use a clean relative blog path, not "${trimmedValue}".`,
|
|
warn
|
|
)
|
|
}
|
|
}
|
|
}
|