mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
chore(deps): drop the archived image-size dependency (#6485)
* chore(deps): drop the archived image-size dependency * improvement(content): cover GIF and warn when OG dimensions are unreadable * improvement(content): read OG dimensions via sharp instead of a bespoke parser
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import matter from 'gray-matter'
|
||||
import sharp from 'sharp'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Guards the content invariant behind `ogImageWidth`/`ogImageHeight` in
|
||||
* `registry-factory`: every local `ogImage` must exist and expose intrinsic
|
||||
* dimensions, or the SEO builders silently fall back to a 1200x630 default that
|
||||
* misdescribes the real asset.
|
||||
*
|
||||
* It also pins the format to one the social crawlers actually accept. SVG is the
|
||||
* trap worth naming: it renders fine in the browser and `sharp` reports
|
||||
* dimensions for it, so a dimension check alone would pass while Open Graph
|
||||
* previews silently break.
|
||||
*/
|
||||
const CRAWLER_SAFE_FORMATS = ['jpeg', 'png', 'webp', 'gif']
|
||||
|
||||
function collectOgImages(): { slug: string; ogImage: string }[] {
|
||||
const entries: { slug: string; ogImage: string }[] = []
|
||||
for (const dir of ['content/blog', 'content/library']) {
|
||||
if (!fs.existsSync(dir)) continue
|
||||
for (const slug of fs.readdirSync(dir)) {
|
||||
const mdxPath = path.join(dir, slug, 'index.mdx')
|
||||
if (!fs.existsSync(mdxPath)) continue
|
||||
const { data } = matter(fs.readFileSync(mdxPath, 'utf-8'))
|
||||
if (typeof data.ogImage === 'string' && !data.ogImage.startsWith('http')) {
|
||||
entries.push({ slug, ogImage: data.ogImage })
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
describe('content OG images', () => {
|
||||
const entries = collectOgImages()
|
||||
|
||||
it('finds local OG images to check', () => {
|
||||
expect(entries.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each(entries)('$slug resolves readable dimensions for $ogImage', async ({ ogImage }) => {
|
||||
const file = path.join('public', ogImage)
|
||||
expect(fs.existsSync(file), `${file} does not exist`).toBe(true)
|
||||
|
||||
const { width, height, format } = await sharp(fs.readFileSync(file)).metadata()
|
||||
expect(width, `${file} has no readable width`).toBeGreaterThan(0)
|
||||
expect(height, `${file} has no readable height`).toBeGreaterThan(0)
|
||||
expect(CRAWLER_SAFE_FORMATS, `${file} is a ${format}, which crawlers reject`).toContain(format)
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,20 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { cache } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import matter from 'gray-matter'
|
||||
import { imageSize } from 'image-size'
|
||||
import { compileMDX } from 'next-mdx-remote/rsc'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import sharp from 'sharp'
|
||||
import { mdxComponents } from '@/lib/content/mdx'
|
||||
import type { Author, ContentMeta, ContentPost, TagWithCount } from '@/lib/content/schema'
|
||||
import { AuthorSchema, ContentFrontmatterSchema } from '@/lib/content/schema'
|
||||
import { byDateDesc, ensureContentDirs, toIsoDate } from '@/lib/content/utils'
|
||||
|
||||
const logger = createLogger('ContentRegistry')
|
||||
|
||||
/** Loads a post's custom MDX component overrides, keyed by slug. */
|
||||
export type ContentComponentLoaders = Record<
|
||||
string,
|
||||
@@ -95,6 +98,10 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg
|
||||
* SEO builders can declare accurate `og:image` and JSON-LD sizes. Returns
|
||||
* null for remote URLs or unreadable files, in which case the builders fall
|
||||
* back to the 1200x630 OG default.
|
||||
*
|
||||
* Uses `sharp`, which only parses headers for `metadata()`. It replaced the
|
||||
* `image-size` package, archived upstream with unpatched DoS advisories in
|
||||
* its ICNS/JXL/HEIF parsers (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq).
|
||||
*/
|
||||
async function readOgImageDimensions(
|
||||
ogImage: string
|
||||
@@ -102,8 +109,14 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg
|
||||
if (ogImage.startsWith('http')) return null
|
||||
try {
|
||||
const buffer = await fs.readFile(path.join(process.cwd(), 'public', ogImage))
|
||||
const { width, height } = imageSize(buffer)
|
||||
return width && height ? { width, height } : null
|
||||
const { width, height } = await sharp(buffer).metadata()
|
||||
if (!width || !height) {
|
||||
logger.warn('OG image has no readable dimensions; falling back to the OG default', {
|
||||
ogImage,
|
||||
})
|
||||
return null
|
||||
}
|
||||
return { width, height }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -174,7 +174,6 @@
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"idb-keyval": "6.2.2",
|
||||
"image-size": "2.0.2",
|
||||
"imapflow": "1.2.4",
|
||||
"input-otp": "^1.4.2",
|
||||
"ioredis": "^5.6.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "simstudio",
|
||||
@@ -276,7 +277,6 @@
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"idb-keyval": "6.2.2",
|
||||
"image-size": "2.0.2",
|
||||
"imapflow": "1.2.4",
|
||||
"input-otp": "^1.4.2",
|
||||
"ioredis": "^5.6.0",
|
||||
@@ -3121,7 +3121,7 @@
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="],
|
||||
"image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="],
|
||||
|
||||
"imapflow": ["imapflow@1.2.4", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.1", "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1", "nodemailer": "7.0.12", "pino": "10.1.0", "socks": "2.8.7" } }, "sha512-X/eRQeje33uZycfopjwoQKKbya+bBIaqpviOFxhPOD24DXU2hMfXwYe9e8j1+ADwFVgTvKq4G2/ljjZK3Y8mvg=="],
|
||||
|
||||
@@ -5155,8 +5155,6 @@
|
||||
|
||||
"pptxgenjs/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="],
|
||||
|
||||
"pptxgenjs/image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="],
|
||||
|
||||
"protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
|
||||
|
||||
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
Reference in New Issue
Block a user