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
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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} />
))}
Expand Down
83 changes: 83 additions & 0 deletions apps/sim/lib/content/registry-factory.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
16 changes: 14 additions & 2 deletions apps/sim/lib/content/registry-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -102,13 +102,21 @@ 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
): 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 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', {
Expand All @@ -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
}
}
Expand Down
Loading