mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(landing): load sharp lazily so a missing native binary can't 500 /blog and /library (#6496)
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import type { NavMenu } from '@/app/(landing)/components/navbar/components/nav-menu-chip/types'
|
||||
|
||||
/**
|
||||
* The Platform menu - Sim's modules. Six items in a three-column grid. Each
|
||||
* description names the outcome the module unlocks for your agents.
|
||||
* The Platform menu - Sim's modules. Five items in a three-column grid, so the
|
||||
* bottom-right cell is empty. Each description names the outcome the module
|
||||
* unlocks for your agents.
|
||||
*/
|
||||
export const PLATFORM_MENU: NavMenu = {
|
||||
label: 'Platform',
|
||||
|
||||
+4
-13
@@ -31,11 +31,9 @@ import type { NavMenu } from '@/app/(landing)/components/navbar/components/nav-m
|
||||
* `--surface-4` ring (`p-[3px]`, overlay shadow) wrapping an inner `--bg`
|
||||
* surface, with the item grid padded inside.
|
||||
*
|
||||
* The grid renders three visual columns on six tracks (each tile spans two),
|
||||
* which keeps six-item menus pixel-identical to a plain three-column grid while
|
||||
* letting a five-item menu center its two-tile last row - the second-to-last
|
||||
* tile starts on track two, so the short row sits symmetrically instead of
|
||||
* leaving a hole in the corner.
|
||||
* The grid is a plain three-column grid filled in reading order, so a menu with
|
||||
* a non-multiple-of-three item count leaves its gap in the bottom-right corner
|
||||
* rather than centering the short row.
|
||||
*/
|
||||
|
||||
interface NavMenuChipProps {
|
||||
@@ -81,14 +79,7 @@ export function NavMenuChip({ menu }: NavMenuChipProps) {
|
||||
<div className={cn(PANEL_BASE, !closed && PANEL_REVEAL)}>
|
||||
<div className='w-[840px] rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-[var(--shadow-overlay)]'>
|
||||
<div className='rounded-lg border border-[var(--border-1)] bg-[var(--bg)] p-2'>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-6 gap-1 [&>*]:col-span-2',
|
||||
items.length % 3 === 2 && '[&>*:nth-last-child(2)]:col-start-2'
|
||||
)}
|
||||
role='group'
|
||||
aria-label={label}
|
||||
>
|
||||
<div className='grid grid-cols-3 gap-1' role='group' aria-label={label}>
|
||||
{items.map((item) => (
|
||||
<NavMenuItem key={item.title} item={item} onSelect={handleSelect} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import fs from 'fs/promises'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* `sharp` resolves a platform-specific `@img/sharp-*` native binary that the
|
||||
* standalone file tracer cannot follow, so a deployment can ship without it. It
|
||||
* must therefore be loaded lazily and its failure contained: an unreadable OG
|
||||
* dimension is optional metadata, not a reason to take down `/blog`, `/library`,
|
||||
* and every tag, author, slug, and RSS route that reads the registry.
|
||||
*
|
||||
* This mock makes `import('sharp')` fail the way a missing native binary does.
|
||||
*/
|
||||
vi.mock('sharp', () => {
|
||||
throw new Error('Could not load the sharp module using the linux-x64 runtime')
|
||||
})
|
||||
|
||||
vi.mock('next-mdx-remote/rsc', () => ({
|
||||
compileMDX: vi.fn(async () => ({ content: null })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/content/mdx', () => ({ mdxComponents: {} }))
|
||||
|
||||
import { createContentRegistry } from '@/lib/content/registry-factory'
|
||||
|
||||
let root: string
|
||||
let contentDir: string
|
||||
let authorsDir: string
|
||||
|
||||
const POST = `---
|
||||
slug: sharp-is-unavailable
|
||||
title: Sharp Is Unavailable
|
||||
description: The registry still serves posts when the native binary is missing.
|
||||
date: 2026-08-10
|
||||
authors: [waleed]
|
||||
ogImage: /blog/missing-og.png
|
||||
canonical: https://sim.ai/blog/sharp-is-unavailable
|
||||
---
|
||||
|
||||
Body copy.
|
||||
`
|
||||
|
||||
const AUTHOR = JSON.stringify({ id: 'waleed', name: 'Waleed Latif' })
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'content-registry-'))
|
||||
contentDir = path.join(root, 'content', 'blog')
|
||||
authorsDir = path.join(root, 'content', 'authors')
|
||||
await fs.mkdir(path.join(contentDir, 'sharp-is-unavailable'), { recursive: true })
|
||||
await fs.mkdir(authorsDir, { recursive: true })
|
||||
await fs.writeFile(path.join(contentDir, 'sharp-is-unavailable', 'index.mdx'), POST)
|
||||
await fs.writeFile(path.join(authorsDir, 'waleed.json'), AUTHOR)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('createContentRegistry without a loadable sharp', () => {
|
||||
it('still lists posts, omitting only the OG dimensions', async () => {
|
||||
const registry = createContentRegistry({ contentDir, authorsDir })
|
||||
|
||||
const posts = await registry.getAllPostMeta()
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0].slug).toBe('sharp-is-unavailable')
|
||||
expect(posts[0].ogImage).toBe('/blog/missing-og.png')
|
||||
expect(posts[0].ogImageWidth).toBeUndefined()
|
||||
expect(posts[0].ogImageHeight).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still resolves a single post by slug', async () => {
|
||||
const registry = createContentRegistry({ contentDir, authorsDir })
|
||||
|
||||
const post = await registry.getPostBySlug('sharp-is-unavailable')
|
||||
|
||||
expect(post?.title).toBe('Sharp Is Unavailable')
|
||||
})
|
||||
})
|
||||
@@ -2,12 +2,12 @@ import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { cache } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import matter from 'gray-matter'
|
||||
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'
|
||||
@@ -102,6 +102,13 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg
|
||||
* 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).
|
||||
*
|
||||
* `sharp` is loaded lazily, never as a top-level import. It resolves a
|
||||
* platform-specific `@img/sharp-*` native binary that the standalone file
|
||||
* tracer cannot follow, so a deployment that ships without it makes
|
||||
* `import 'sharp'` throw at module scope — which would take down every route
|
||||
* that touches this registry (`/blog`, `/library`, their tag, author, slug,
|
||||
* and RSS routes) rather than degrading one optional OG dimension.
|
||||
*/
|
||||
async function readOgImageDimensions(
|
||||
ogImage: string
|
||||
@@ -109,6 +116,7 @@ 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 sharp = (await import('sharp')).default
|
||||
const { width, height } = await sharp(buffer).metadata()
|
||||
if (!width || !height) {
|
||||
logger.warn('OG image has no readable dimensions; falling back to the OG default', {
|
||||
@@ -117,7 +125,11 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg
|
||||
return null
|
||||
}
|
||||
return { width, height }
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read OG image dimensions; falling back to the OG default', {
|
||||
ogImage,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user