Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/sim/lib/content/og-image.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
19 changes: 16 additions & 3 deletions apps/sim/lib/content/registry-factory.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -95,15 +98,25 @@ 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
): Promise<{ width: number; height: number } | null> {
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
}
Expand Down
1 change: 0 additions & 1 deletion apps/sim/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 2 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading