diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts index 31675b86ec0..4b49af2d50b 100644 --- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts +++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts @@ -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', diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx index f0d71450684..0d0954ef438 100644 --- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx @@ -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) {
-
*]:col-span-2', - items.length % 3 === 2 && '[&>*:nth-last-child(2)]:col-start-2' - )} - role='group' - aria-label={label} - > +
{items.map((item) => ( ))} diff --git a/apps/sim/lib/content/registry-factory.test.ts b/apps/sim/lib/content/registry-factory.test.ts new file mode 100644 index 00000000000..4a160728929 --- /dev/null +++ b/apps/sim/lib/content/registry-factory.test.ts @@ -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('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/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') + }) +}) diff --git a/apps/sim/lib/content/registry-factory.ts b/apps/sim/lib/content/registry-factory.ts index 8df6fceba6f..a526cfd8b6c 100644 --- a/apps/sim/lib/content/registry-factory.ts +++ b/apps/sim/lib/content/registry-factory.ts @@ -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 } }