mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix(www): use imgSocial for blog OG images (#43842)
## What kind of change does this PR introduce? - Bug fix that resolves DEPR-396 - Additional improvements to blog post image handling ## What is the current behavior? Blog post Open Graph metadata often prefers `imgThumb` over `imgSocial`, so social previews on X, iMessage, and similar surfaces can render the on-site thumbnail instead of the intended social image. The image selection and path-normalization rules are also duplicated across blog surfaces, which makes the precedence rules easy to drift. ## What is the new behavior? - Centralizes blog image handling in `apps/www/lib/blog-images.ts` - Uses `imgSocial` first, then `imgThumb`, for blog OG/Twitter metadata - Uses `imgThumb` first, then `imgSocial`, then the placeholder, for blog thumbnails and post hero images - Normalizes relative blog image paths into absolute URLs for metadata - Adds warning-only validation during content reads/builds for partial or malformed `imgSocial` / `imgThumb` config - Updates a few recent blog posts so `imgThumb` is a thumbnail-only asset instead of duplicating the social image ## Additional context - Replaces #42319 with the additional above fixes - Added unit coverage for the shared image helper --------- Co-authored-by: Alan Daniel <stylesshjs@gmail.com>
This commit is contained in:
1 parent
676c237422
commit
48b96e8a79
14 files changed
+351
-54
No files matched your search
@@ -15,11 +15,9 @@ tags:
|
||||
- infrastructure
|
||||
toc_depth: 3
|
||||
imgSocial: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=announcement&layout=text-only©=When+to+use%0A%5BRead+Replicas%5D%0Avs.+bigger+compute'
|
||||
imgThumb: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=announcement&layout=text-only©=When+to+use%0A%5BRead+Replicas%5D%0Avs.+bigger+compute'
|
||||
imgThumb: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=ruler&layout=icon-only&icon=icon-database.svg'
|
||||
---
|
||||
|
||||
# When to use Read Replicas vs. bigger compute
|
||||
|
||||
When your database starts slowing down, you face a choice: make your existing database bigger, or spread the load across multiple databases. Both approaches work. Neither is universally correct. The right answer depends on your workload, your budget, and where the bottleneck actually is.
|
||||
|
||||
This post walks through how to diagnose what is causing your database to slow down, when vertical scaling (bigger compute) makes sense, when horizontal read scaling (Read Replicas) is the better path, and how to make the decision with real numbers.
|
||||
|
||||
@@ -11,7 +11,7 @@ tags:
|
||||
- mcp
|
||||
- integrations
|
||||
imgSocial: '2026-02-03-supabase-is-now-an-official-claude-connector/og.png'
|
||||
imgThumb: '2026-02-03-supabase-is-now-an-official-claude-connector/thumb.png'
|
||||
imgThumb: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=partnerships&layout=icon-only&icon=supabase.svg&icon2=1770136381461-Claude.svg'
|
||||
toc_depth: 2
|
||||
---
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: 'X / Twitter OAuth 2.0 is now available for Supabase Auth'
|
||||
description: 'You can now add "Sign in with X" to your application using the new X / Twitter (OAuth 2.0) provider in Supabase Auth.'
|
||||
author: fady
|
||||
imgSocial: x-twitter-oauth-2-provider/og.png
|
||||
imgThumb: x-twitter-oauth-2-provider/og.png
|
||||
imgThumb: https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=ruler&layout=icon-only©=Introducing+Supabase%0Afor+%5BPlatforms%5D&icon=social-x.svg
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -3,6 +3,7 @@ import { draftMode } from 'next/headers'
|
||||
|
||||
import BlogPostClient from './BlogPostClient'
|
||||
import { SITE_ORIGIN } from '@/lib/constants'
|
||||
import { getAbsoluteBlogSocialImage } from '@/lib/blog-images'
|
||||
import { getAllPostSlugs, getPostdata, getSortedPosts } from '@/lib/posts'
|
||||
import type { Blog, BlogData, PostReturnType } from '@/types/post'
|
||||
|
||||
@@ -40,7 +41,6 @@ export async function generateMetadata({ params }: { params: Promise<Params> }):
|
||||
}
|
||||
}
|
||||
|
||||
const { isEnabled: isDraft } = await draftMode()
|
||||
const matter = (await import('gray-matter')).default
|
||||
|
||||
// Try to get static markdown post first
|
||||
@@ -48,8 +48,7 @@ export async function generateMetadata({ params }: { params: Promise<Params> }):
|
||||
const postContent = await getPostdata(slug, '_blog')
|
||||
const parsedContent = matter(postContent) as unknown as MatterReturn
|
||||
const blogPost = parsedContent.data
|
||||
const blogImage = blogPost.imgThumb || blogPost.imgSocial
|
||||
const metaImageUrl = blogImage && blogImage.startsWith('http') ? blogImage : undefined
|
||||
const metaImageUrl = getAbsoluteBlogSocialImage(blogPost, SITE_ORIGIN)
|
||||
|
||||
return {
|
||||
title: blogPost.title,
|
||||
|
||||
@@ -3,6 +3,11 @@ import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import authors from '@/lib/authors.json'
|
||||
import {
|
||||
BLOG_GRID_IMAGE_SIZES,
|
||||
BLOG_PLACEHOLDER_IMAGE,
|
||||
getBlogThumbnailImage,
|
||||
} from '@/lib/blog-images'
|
||||
import type Author from '@/types/author'
|
||||
import type PostTypes from '@/types/post'
|
||||
|
||||
@@ -24,15 +29,7 @@ const BlogGridItem = ({ post }: Props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const resolveImagePath = (img: string | undefined): string | null => {
|
||||
if (!img) return null
|
||||
return img.startsWith('/') || img.startsWith('http') ? img : `/images/blog/${img}`
|
||||
}
|
||||
|
||||
const imageUrl =
|
||||
resolveImagePath(post.imgThumb) ||
|
||||
resolveImagePath(post.imgSocial) ||
|
||||
'/images/blog/blog-placeholder.png'
|
||||
const imageUrl = getBlogThumbnailImage(post) ?? BLOG_PLACEHOLDER_IMAGE
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -45,8 +42,7 @@ const BlogGridItem = ({ post }: Props) => {
|
||||
<div className="border-default relative mb-3 w-full aspect-[1.91/1] overflow-hidden rounded-lg border shadow-sm">
|
||||
<Image
|
||||
fill
|
||||
sizes="100%"
|
||||
quality={100}
|
||||
sizes={BLOG_GRID_IMAGE_SIZES}
|
||||
src={imageUrl}
|
||||
className="scale-100 object-cover overflow-hidden"
|
||||
alt={`${post.title} thumbnail`}
|
||||
@@ -58,7 +54,7 @@ const BlogGridItem = ({ post }: Props) => {
|
||||
<p>{dayjs(post.date).format('D MMM YYYY')}</p>
|
||||
{post.readingTime && (
|
||||
<>
|
||||
<p>•</p>
|
||||
<p>·</p>
|
||||
<p>{post.readingTime}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ComponentType } from 'react'
|
||||
import type { PostReturnType, ProcessedBlogData, StaticAuthor, Tag } from 'types/post'
|
||||
import { Badge } from 'ui'
|
||||
|
||||
import { BLOG_POST_HERO_IMAGE_SIZES, getBlogThumbnailImage } from '@/lib/blog-images'
|
||||
import mdxComponents from '@/lib/mdx/mdxComponents'
|
||||
|
||||
const ShareArticleActions = dynamic(() => import('@/components/Blog/ShareArticleActions'))
|
||||
@@ -131,11 +132,9 @@ const BlogPostRenderer = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
const imageUrl = blogMetaData.imgThumb
|
||||
? blogMetaData.imgThumb.startsWith('/') || blogMetaData.imgThumb.startsWith('http')
|
||||
? blogMetaData.imgThumb
|
||||
: `/images/blog/${blogMetaData.imgThumb}`
|
||||
: ''
|
||||
const imageUrl = getBlogThumbnailImage(blogMetaData, {
|
||||
fallbackToPlaceholder: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -168,7 +167,7 @@ const BlogPostRenderer = ({
|
||||
<h1 className="text-2xl sm:text-4xl">{blogMetaData.title}</h1>
|
||||
<div className="text-light flex space-x-3 text-sm">
|
||||
<p>{dayjs(blogMetaData.date).format('DD MMM YYYY')}</p>
|
||||
<p>•</p>
|
||||
<p>·</p>
|
||||
<p>{(blogMetaData as any).readingTime}</p>
|
||||
</div>
|
||||
{authors.length > 0 && (
|
||||
@@ -231,14 +230,13 @@ const BlogPostRenderer = ({
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
) : (
|
||||
blogMetaData.imgThumb && (
|
||||
imageUrl && (
|
||||
<div className="hidden md:block relative mb-8 w-full aspect-[1.91/1] overflow-auto rounded-lg border">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={blogMetaData.title}
|
||||
fill
|
||||
quality={100}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
sizes={BLOG_POST_HERO_IMAGE_SIZES}
|
||||
className="object-cover m-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,11 @@ import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import authors from '@/lib/authors.json'
|
||||
import {
|
||||
BLOG_FEATURED_IMAGE_SIZES,
|
||||
BLOG_PLACEHOLDER_IMAGE,
|
||||
getBlogThumbnailImage,
|
||||
} from '@/lib/blog-images'
|
||||
import type PostTypes from '@/types/post'
|
||||
|
||||
function FeaturedThumb(blog: PostTypes) {
|
||||
@@ -22,15 +27,7 @@ function FeaturedThumb(blog: PostTypes) {
|
||||
}
|
||||
|
||||
function renderFeaturedThumb(blog: PostTypes, author: any[]) {
|
||||
const resolveImagePath = (img: string | undefined): string | null => {
|
||||
if (!img) return null
|
||||
return img.startsWith('/') || img.startsWith('http') ? img : `/images/blog/${img}`
|
||||
}
|
||||
|
||||
const imageUrl =
|
||||
resolveImagePath(blog.imgThumb) ||
|
||||
resolveImagePath(blog.imgSocial) ||
|
||||
'/images/blog/blog-placeholder.png'
|
||||
const imageUrl = getBlogThumbnailImage(blog) ?? BLOG_PLACEHOLDER_IMAGE
|
||||
|
||||
return (
|
||||
<div key={blog.slug} className="w-full">
|
||||
@@ -42,8 +39,7 @@ function renderFeaturedThumb(blog: PostTypes, author: any[]) {
|
||||
<Image
|
||||
src={imageUrl}
|
||||
fill
|
||||
sizes="100%"
|
||||
quality={100}
|
||||
sizes={BLOG_FEATURED_IMAGE_SIZES}
|
||||
priority
|
||||
className="object-cover bg-alternative"
|
||||
alt="blog thumbnail"
|
||||
@@ -52,7 +48,7 @@ function renderFeaturedThumb(blog: PostTypes, author: any[]) {
|
||||
<div className="flex flex-col space-y-2 lg:col-span-4 xl:justify-center max-w-xl">
|
||||
<div className="text-lighter flex space-x-2 text-sm">
|
||||
<span>{blog.formattedDate}</span>
|
||||
<span>•</span>
|
||||
<span>·</span>
|
||||
<span>{blog.readingTime}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
// import authors from 'lib/authors.json'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
// import type Author from '~/types/author'
|
||||
import type PostTypes from '../../types/post'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
interface Props {
|
||||
event: PostTypes
|
||||
}
|
||||
@@ -58,7 +58,7 @@ const EventGridItem = ({ event }: Props) => {
|
||||
<p>{dayjs(event.date).format('D MMM YYYY')}</p>
|
||||
{event.readingTime && (
|
||||
<>
|
||||
<p>•</p>
|
||||
<p>·</p>
|
||||
<p>{event.readingTime}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { MDXRemote } from 'next-mdx-remote'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import React from 'react'
|
||||
import CTABanner from '~/components/CTABanner'
|
||||
import DefaultLayout from '~/components/Layouts/Default'
|
||||
import { getAbsoluteBlogSocialImage } from '~/lib/blog-images'
|
||||
import { generateReadingTime } from '~/lib/helpers'
|
||||
|
||||
interface Props {
|
||||
@@ -31,8 +31,6 @@ const LayoutComparison = ({ components, props }: Props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const { basePath } = useRouter()
|
||||
|
||||
const NextCard = (props: any) => {
|
||||
const { post, label, className } = props
|
||||
return (
|
||||
@@ -77,12 +75,8 @@ const LayoutComparison = ({ components, props }: Props) => {
|
||||
}),
|
||||
},
|
||||
images: (() => {
|
||||
const img = props.blog.imgSocial || props.blog.imgThumb
|
||||
if (!img) return []
|
||||
const url =
|
||||
img.startsWith('/') || img.startsWith('http')
|
||||
? img
|
||||
: `https://supabase.com${basePath}/images/blog/${img}`
|
||||
const url = getAbsoluteBlogSocialImage(props.blog, 'https://supabase.com')
|
||||
if (!url) return []
|
||||
return [{ url }]
|
||||
})(),
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
BLOG_PLACEHOLDER_IMAGE,
|
||||
getAbsoluteBlogSocialImage,
|
||||
getBlogSocialImage,
|
||||
getBlogThumbnailImage,
|
||||
resolveBlogImagePath,
|
||||
validateBlogFrontmatterImages,
|
||||
} from './blog-images'
|
||||
|
||||
describe('blog image helpers', () => {
|
||||
it('prefers imgThumb for on-site blog thumbnails', () => {
|
||||
expect(
|
||||
getBlogThumbnailImage({
|
||||
imgThumb: 'example/thumb.png',
|
||||
imgSocial: 'example/og.png',
|
||||
})
|
||||
).toBe('/images/blog/example/thumb.png')
|
||||
})
|
||||
|
||||
it('falls back to imgSocial for on-site blog thumbnails', () => {
|
||||
expect(
|
||||
getBlogThumbnailImage({
|
||||
imgSocial: 'example/og.png',
|
||||
})
|
||||
).toBe('/images/blog/example/og.png')
|
||||
})
|
||||
|
||||
it('prefers imgSocial for social metadata', () => {
|
||||
expect(
|
||||
getBlogSocialImage({
|
||||
imgThumb: 'example/thumb.png',
|
||||
imgSocial: 'example/og.png',
|
||||
})
|
||||
).toBe('/images/blog/example/og.png')
|
||||
})
|
||||
|
||||
it('returns absolute URLs unchanged and prefixes relative paths', () => {
|
||||
expect(resolveBlogImagePath('https://example.com/og.png')).toBe('https://example.com/og.png')
|
||||
expect(resolveBlogImagePath('/images/blog/example/og.png')).toBe('/images/blog/example/og.png')
|
||||
expect(resolveBlogImagePath('example/og.png')).toBe('/images/blog/example/og.png')
|
||||
})
|
||||
|
||||
it('builds absolute social image URLs', () => {
|
||||
expect(
|
||||
getAbsoluteBlogSocialImage(
|
||||
{
|
||||
imgSocial: 'example/og.png',
|
||||
},
|
||||
'https://supabase.com'
|
||||
)
|
||||
).toBe('https://supabase.com/images/blog/example/og.png')
|
||||
})
|
||||
|
||||
it('uses the placeholder when neither image is present', () => {
|
||||
expect(getBlogThumbnailImage({})).toBe(BLOG_PLACEHOLDER_IMAGE)
|
||||
expect(getBlogThumbnailImage({}, { fallbackToPlaceholder: false })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('warns when blog frontmatter is missing one of the image fields', () => {
|
||||
const warn = vi.fn()
|
||||
|
||||
validateBlogFrontmatterImages(
|
||||
{
|
||||
imgThumb: 'example/thumb.png',
|
||||
},
|
||||
'/tmp/example-post.mdx',
|
||||
warn
|
||||
)
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('missing "imgSocial"'))
|
||||
})
|
||||
|
||||
it('warns when a blog image uses the prefixed public path', () => {
|
||||
const warn = vi.fn()
|
||||
|
||||
validateBlogFrontmatterImages(
|
||||
{
|
||||
imgThumb: '/images/blog/example/thumb.png',
|
||||
imgSocial: 'example/og.png',
|
||||
},
|
||||
'/tmp/example-prefixed-post.mdx',
|
||||
warn
|
||||
)
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('should not include the "/images/blog/" prefix')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
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
|
||||
}
|
||||
|
||||
export function getAbsoluteBlogSocialImage(
|
||||
{ imgThumb, imgSocial }: BlogImageFields,
|
||||
siteOrigin: string = SITE_ORIGIN
|
||||
) {
|
||||
const image = imgSocial || imgThumb
|
||||
return image ? toAbsoluteBlogImageUrl(image, siteOrigin) : undefined
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'fs'
|
||||
import matter from 'gray-matter'
|
||||
import path from 'path'
|
||||
import { validateBlogFrontmatterImages } from './blog-images'
|
||||
import { generateReadingTime } from './helpers'
|
||||
|
||||
type Directories = '_blog' | '_case-studies' | '_customers' | '_alternatives' | '_events'
|
||||
@@ -66,6 +67,10 @@ export const getSortedPosts = ({
|
||||
content: string
|
||||
}
|
||||
|
||||
if (directory === '_blog') {
|
||||
validateBlogFrontmatterImages(data as { imgSocial?: string; imgThumb?: string }, fullPath)
|
||||
}
|
||||
|
||||
const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric', year: 'numeric' }
|
||||
const formattedDate = new Date(data.date).toLocaleDateString('en-IN', options)
|
||||
|
||||
@@ -169,6 +174,12 @@ export const getPostdata = async (slug: string, directory: string) => {
|
||||
|
||||
const fullPath = path.join(postDirectory, found)
|
||||
const postContent = fs.readFileSync(fullPath, 'utf8')
|
||||
|
||||
if (directory === '_blog') {
|
||||
const { data } = matter(postContent) as unknown as { data: { [key: string]: any } }
|
||||
validateBlogFrontmatterImages(data as { imgSocial?: string; imgThumb?: string }, fullPath)
|
||||
}
|
||||
|
||||
return postContent
|
||||
}
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -13,6 +13,76 @@ dayjs.extend(advancedFormat)
|
||||
|
||||
// Constants
|
||||
const FILENAME_SUBSTRING = 11 // based on YYYY-MM-DD format
|
||||
const warnedBlogImageIssues = new Set()
|
||||
|
||||
const warnBlogImageIssue = (key, message) => {
|
||||
if (warnedBlogImageIssues.has(key)) return
|
||||
|
||||
warnedBlogImageIssues.add(key)
|
||||
console.warn(message)
|
||||
}
|
||||
|
||||
const validateBlogFrontmatterImages = (frontmatter, filePath) => {
|
||||
const hasImgSocial = typeof frontmatter.imgSocial !== 'undefined'
|
||||
const hasImgThumb = typeof frontmatter.imgThumb !== 'undefined'
|
||||
|
||||
if (hasImgSocial && !hasImgThumb) {
|
||||
warnBlogImageIssue(
|
||||
`${filePath}:imgThumb:missing`,
|
||||
`[blog images] ${filePath}: missing "imgThumb". Adding it keeps on-site thumbnails separate from social previews.`
|
||||
)
|
||||
}
|
||||
|
||||
if (hasImgThumb && !hasImgSocial) {
|
||||
warnBlogImageIssue(
|
||||
`${filePath}:imgSocial:missing`,
|
||||
`[blog images] ${filePath}: missing "imgSocial". Adding it keeps social previews separate from on-site thumbnails.`
|
||||
)
|
||||
}
|
||||
|
||||
const imageFields = [
|
||||
['imgSocial', frontmatter.imgSocial],
|
||||
['imgThumb', frontmatter.imgThumb],
|
||||
]
|
||||
|
||||
for (const [fieldName, imageValue] of imageFields) {
|
||||
if (typeof imageValue === 'undefined') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof imageValue !== 'string') {
|
||||
warnBlogImageIssue(
|
||||
`${filePath}:${fieldName}:invalid-type`,
|
||||
`[blog images] ${filePath}: "${fieldName}" should be a string URL or a relative blog image path.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const trimmedValue = imageValue.trim()
|
||||
|
||||
if (!trimmedValue) {
|
||||
warnBlogImageIssue(
|
||||
`${filePath}:${fieldName}:empty`,
|
||||
`[blog images] ${filePath}: "${fieldName}" is empty. Remove it or provide a valid image path.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedValue.startsWith('/images/blog/')) {
|
||||
warnBlogImageIssue(
|
||||
`${filePath}:${fieldName}:prefixed`,
|
||||
`[blog images] ${filePath}: "${fieldName}" should not include the "/images/blog/" prefix. Use a relative path like "my-post/og.png" instead.`
|
||||
)
|
||||
}
|
||||
|
||||
if (trimmedValue.startsWith('./') || trimmedValue.startsWith('../')) {
|
||||
warnBlogImageIssue(
|
||||
`${filePath}:${fieldName}:relative-dot`,
|
||||
`[blog images] ${filePath}: "${fieldName}" should use a clean relative blog path, not "${trimmedValue}".`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes Safari dates sorting bug
|
||||
@@ -53,6 +123,8 @@ const getStaticBlogPosts = () => {
|
||||
const fileContents = fsSync.readFileSync(fullPath, 'utf8')
|
||||
const { data } = matter(fileContents)
|
||||
|
||||
validateBlogFrontmatterImages(data, fullPath)
|
||||
|
||||
const options = { month: 'long', day: 'numeric', year: 'numeric' }
|
||||
const formattedDate = new Date(data.date).toLocaleDateString('en-IN', options)
|
||||
|
||||
|
||||
Reference in new issue
Block a user