diff --git a/.gitignore b/.gitignore index b63f08dd25..7c94d60f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,11 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .claude/scheduled_tasks.lock .dev.vars +# Generated OG images (produced at build time by scripts/generate-og-images.ts) +apps/landing/public/og-image.png +apps/guides/public/og-image.png +apps/guides/public/og/ + # Git worktrees .worktrees/ .worktrees diff --git a/apps/guides/__tests__/og-image.test.ts b/apps/guides/__tests__/og-image.test.ts new file mode 100644 index 0000000000..c910e48988 --- /dev/null +++ b/apps/guides/__tests__/og-image.test.ts @@ -0,0 +1,113 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { getAllPosts } from '../lib/mdx-static'; +import { guidesMetadata } from '../lib/metadata'; + +const APP_DIR = path.resolve(__dirname, '..'); +const PUBLIC_DIR = path.join(APP_DIR, 'public'); +const OG_DIR = path.join(PUBLIC_DIR, 'og'); +const ROOT_OG_PATH = path.join(PUBLIC_DIR, 'og-image.png'); + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** Read a uint32 big-endian from a buffer at offset. */ +function readUint32BE(buf: Buffer, offset: number): number { + return buf.readUInt32BE(offset); +} + +function assertValidPng(filePath: string): void { + const buf = fs.readFileSync(filePath); + expect(buf.subarray(0, 8), `${path.basename(filePath)} PNG signature`).toEqual(PNG_SIGNATURE); + const width = readUint32BE(buf, 16); + const height = readUint32BE(buf, 20); + expect(width, `${path.basename(filePath)} width`).toBe(1200); + expect(height, `${path.basename(filePath)} height`).toBe(630); + expect(buf.length, `${path.basename(filePath)} size`).toBeGreaterThan(1024); +} + +describe('guides OG image generation', () => { + beforeAll(() => { + execSync('bun run scripts/generate-og-images.ts', { + cwd: APP_DIR, + stdio: 'inherit', + }); + }); + + it('generates public/og-image.png', () => { + expect(fs.existsSync(ROOT_OG_PATH)).toBe(true); + }); + + it('root og-image.png is a valid 1200×630 PNG', () => { + assertValidPng(ROOT_OG_PATH); + }); + + it('generates public/og/ directory', () => { + expect(fs.existsSync(OG_DIR)).toBe(true); + }); + + it('generates a per-post PNG for every post', () => { + const posts = getAllPosts(); + expect(posts.length).toBeGreaterThan(0); + + for (const post of posts) { + const filePath = path.join(OG_DIR, `${post.slug}.png`); + expect(fs.existsSync(filePath), `missing: og/${post.slug}.png`).toBe(true); + } + }); + + it('every per-post PNG is a valid 1200×630 PNG', () => { + const posts = getAllPosts(); + for (const post of posts) { + assertValidPng(path.join(OG_DIR, `${post.slug}.png`)); + } + }); +}); + +describe('guides layout metadata', () => { + it('includes openGraph.images pointing to /og-image.png', () => { + const images = (guidesMetadata.openGraph as { images?: unknown })?.images; + expect(images).toBeDefined(); + const first = Array.isArray(images) ? images[0] : images; + const url = typeof first === 'string' ? first : (first as { url: string })?.url; + expect(url).toBe('/og-image.png'); + }); + + it('includes twitter.images pointing to /og-image.png', () => { + const images = (guidesMetadata.twitter as { images?: unknown })?.images; + expect(images).toBeDefined(); + const first = Array.isArray(images) ? images[0] : images; + expect(first).toBe('/og-image.png'); + }); +}); + +describe('guides per-slug page metadata', () => { + it('generateMetadata sets openGraph.images to /og/[slug].png', async () => { + // Dynamically import to avoid top-level JSX issues in test runner + const { generateMetadata } = await import('../app/guide/[slug]/page'); + const posts = getAllPosts(); + const post = posts[0]; + if (!post) throw new Error('No posts found'); + + const meta = await generateMetadata({ params: Promise.resolve({ slug: post.slug }) }); + const images = (meta.openGraph as { images?: unknown })?.images; + expect(images).toBeDefined(); + const first = Array.isArray(images) ? images[0] : images; + const url = typeof first === 'string' ? first : (first as { url: string })?.url; + expect(url).toBe(`/og/${post.slug}.png`); + }); + + it('generateMetadata sets twitter.images to /og/[slug].png', async () => { + const { generateMetadata } = await import('../app/guide/[slug]/page'); + const posts = getAllPosts(); + const post = posts[0]; + if (!post) throw new Error('No posts found'); + + const meta = await generateMetadata({ params: Promise.resolve({ slug: post.slug }) }); + const images = (meta.twitter as { images?: unknown })?.images; + expect(images).toBeDefined(); + const first = Array.isArray(images) ? images[0] : images; + expect(first).toBe(`/og/${post.slug}.png`); + }); +}); diff --git a/apps/guides/app/guide/[slug]/opengraph-image.tsx b/apps/guides/app/guide/[slug]/opengraph-image.tsx index 8c19a53c2b..08c0e696de 100644 --- a/apps/guides/app/guide/[slug]/opengraph-image.tsx +++ b/apps/guides/app/guide/[slug]/opengraph-image.tsx @@ -1,9 +1,14 @@ import { getAllPosts, getPostBySlug } from 'guides-app/lib/mdx-static'; +import { + getPostOgImageElement, + OG_IMAGE_CONTENT_TYPE, + OG_IMAGE_SIZE, +} from 'guides-app/lib/og-image'; import { ImageResponse } from 'next/og'; export const dynamic = 'force-static'; -export const size = { width: 1200, height: 630 }; -export const contentType = 'image/png'; +export const size = OG_IMAGE_SIZE; +export const contentType = OG_IMAGE_CONTENT_TYPE; export async function generateStaticParams() { return getAllPosts().map((post) => ({ slug: post.slug })); @@ -13,98 +18,12 @@ export default async function Image({ params }: { params: Promise<{ slug: string const { slug } = await params; const post = getPostBySlug(slug); - const title = post?.title ?? 'PackRat Guides'; - const description = post?.description ?? 'Expert hiking and outdoor guides'; - const categories = post?.categories ?? []; - return new ImageResponse( -
-
-
🏔️
-
- PackRat Guides -
-
- -
- {categories.length > 0 && ( -
- {categories.slice(0, 3).map((cat) => ( -
- {cat} -
- ))} -
- )} -
50 ? '44px' : '56px', - fontWeight: 700, - color: 'white', - lineHeight: 1.15, - letterSpacing: '-1px', - maxWidth: '900px', - }} - > - {title} -
-
- {description.length > 120 ? `${description.slice(0, 117)}...` : description} -
-
- -
- guides.packratai.com -
-
, + getPostOgImageElement({ + title: post?.title ?? 'PackRat Guides', + description: post?.description ?? 'Expert hiking and outdoor guides', + categories: post?.categories ?? [], + }), { ...size }, ); } diff --git a/apps/guides/app/guide/[slug]/page.tsx b/apps/guides/app/guide/[slug]/page.tsx index e84fd05de4..1e672ecf0d 100644 --- a/apps/guides/app/guide/[slug]/page.tsx +++ b/apps/guides/app/guide/[slug]/page.tsx @@ -39,12 +39,14 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str siteName: 'PackRat Guides', publishedTime: post.date, tags: post.categories, + images: [{ url: `/og/${slug}.png`, width: 1200, height: 630, alt: post.title }], }, twitter: { card: 'summary_large_image', title: post.title, description: post.description, creator: '@packratai', + images: [`/og/${slug}.png`], }, }; } diff --git a/apps/guides/app/layout.tsx b/apps/guides/app/layout.tsx index eb159f716b..03111135e8 100644 --- a/apps/guides/app/layout.tsx +++ b/apps/guides/app/layout.tsx @@ -3,8 +3,7 @@ import Footer from 'guides-app/components/footer'; import Header from 'guides-app/components/header'; import { QueryProvider } from 'guides-app/components/providers/query-provider'; import { ThemeProvider } from 'guides-app/components/theme-provider'; -import { siteConfig } from 'guides-app/lib/config'; -import type { Metadata } from 'next'; +import { guidesMetadata } from 'guides-app/lib/metadata'; import { Mona_Sans as FontSans } from 'next/font/google'; import type React from 'react'; import './globals.css'; @@ -15,45 +14,7 @@ const fontSans = FontSans({ weight: ['400', '500', '600', '700'], }); -export const metadata: Metadata = { - title: { - default: 'PackRat Guides | Hiking & Outdoor Adventures', - template: '%s | PackRat Guides', - }, - description: 'Expert hiking and outdoor guides to help you prepare for your next adventure', - keywords: [ - 'hiking guides', - 'outdoor adventures', - 'trail guides', - 'camping', - 'backpacking', - 'gear reviews', - 'wilderness skills', - 'outdoor planning', - ], - authors: [{ name: 'PackRat Team', url: 'https://packrat.world' }], - creator: 'PackRat Team', - metadataBase: new URL(siteConfig.url), - openGraph: { - type: 'website', - locale: 'en_US', - url: siteConfig.url, - siteName: 'PackRat Guides', - title: 'PackRat Guides | Hiking & Outdoor Adventures', - description: 'Expert hiking and outdoor guides to help you prepare for your next adventure', - }, - twitter: { - card: 'summary_large_image', - title: 'PackRat Guides | Hiking & Outdoor Adventures', - description: 'Expert hiking and outdoor guides to help you prepare for your next adventure', - creator: '@packratai', - }, - icons: { - icon: [{ url: '/PackRatGuides.ico', type: 'image/x-icon' }], - shortcut: '/favicon-16x16.png', - apple: '/apple-touch-icon.png', - }, -}; +export const metadata = guidesMetadata; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/guides/app/opengraph-image.tsx b/apps/guides/app/opengraph-image.tsx index ade5c5fcc9..0dbd5a11ae 100644 --- a/apps/guides/app/opengraph-image.tsx +++ b/apps/guides/app/opengraph-image.tsx @@ -1,93 +1,14 @@ +import { + getGuidesOgImageElement, + OG_IMAGE_CONTENT_TYPE, + OG_IMAGE_SIZE, +} from 'guides-app/lib/og-image'; import { ImageResponse } from 'next/og'; export const dynamic = 'force-static'; -export const size = { width: 1200, height: 630 }; -export const contentType = 'image/png'; +export const size = OG_IMAGE_SIZE; +export const contentType = OG_IMAGE_CONTENT_TYPE; export default function Image() { - return new ImageResponse( -
-
-
- 🏔️ -
-
- PackRat Guides -
-
-
- Expert hiking and outdoor guides for your next adventure -
-
- {['Trail Guides', 'Gear Reviews', 'Survival Skills'].map((tag) => ( -
- {tag} -
- ))} -
-
, - { ...size }, - ); + return new ImageResponse(getGuidesOgImageElement(), { ...size }); } diff --git a/apps/guides/app/twitter-image.tsx b/apps/guides/app/twitter-image.tsx index 61da1f7581..0dbd5a11ae 100644 --- a/apps/guides/app/twitter-image.tsx +++ b/apps/guides/app/twitter-image.tsx @@ -1,70 +1,14 @@ +import { + getGuidesOgImageElement, + OG_IMAGE_CONTENT_TYPE, + OG_IMAGE_SIZE, +} from 'guides-app/lib/og-image'; import { ImageResponse } from 'next/og'; export const dynamic = 'force-static'; -export const size = { width: 1200, height: 630 }; -export const contentType = 'image/png'; +export const size = OG_IMAGE_SIZE; +export const contentType = OG_IMAGE_CONTENT_TYPE; export default function Image() { - return new ImageResponse( -
-
-
- 🏔️ -
-
- PackRat Guides -
-
-
- Expert hiking and outdoor guides for your next adventure -
-
, - { ...size }, - ); + return new ImageResponse(getGuidesOgImageElement(), { ...size }); } diff --git a/apps/guides/lib/metadata.ts b/apps/guides/lib/metadata.ts new file mode 100644 index 0000000000..f0e826959d --- /dev/null +++ b/apps/guides/lib/metadata.ts @@ -0,0 +1,44 @@ +import { siteConfig } from 'guides-app/lib/config'; +import type { Metadata } from 'next'; + +export const guidesMetadata: Metadata = { + title: { + default: 'PackRat Guides | Hiking & Outdoor Adventures', + template: '%s | PackRat Guides', + }, + description: 'Expert hiking and outdoor guides to help you prepare for your next adventure', + keywords: [ + 'hiking guides', + 'outdoor adventures', + 'trail guides', + 'camping', + 'backpacking', + 'gear reviews', + 'wilderness skills', + 'outdoor planning', + ], + authors: [{ name: 'PackRat Team', url: 'https://packrat.world' }], + creator: 'PackRat Team', + metadataBase: new URL(siteConfig.url), + openGraph: { + type: 'website', + locale: 'en_US', + url: siteConfig.url, + siteName: 'PackRat Guides', + title: 'PackRat Guides | Hiking & Outdoor Adventures', + description: 'Expert hiking and outdoor guides to help you prepare for your next adventure', + images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'PackRat Guides' }], + }, + twitter: { + card: 'summary_large_image', + title: 'PackRat Guides | Hiking & Outdoor Adventures', + description: 'Expert hiking and outdoor guides to help you prepare for your next adventure', + creator: '@packratai', + images: ['/og-image.png'], + }, + icons: { + icon: [{ url: '/PackRatGuides.ico', type: 'image/x-icon' }], + shortcut: '/favicon-16x16.png', + apple: '/apple-touch-icon.png', + }, +}; diff --git a/apps/guides/lib/og-image.tsx b/apps/guides/lib/og-image.tsx new file mode 100644 index 0000000000..e20432ca81 --- /dev/null +++ b/apps/guides/lib/og-image.tsx @@ -0,0 +1,210 @@ +import type { ReactElement } from 'react'; + +export const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const; +export const OG_IMAGE_CONTENT_TYPE = 'image/png' as const; + +/** Returns the JSX element for the root Guides Open Graph / Twitter card image. */ +export function getGuidesOgImageElement(): ReactElement { + return ( +
+
+
+
+
+
+ PackRat Guides +
+
+
+ Expert hiking and outdoor guides for your next adventure +
+
+ {['Trail Guides', 'Gear Reviews', 'Survival Skills'].map((tag) => ( +
+ {tag} +
+ ))} +
+
+ ); +} + +export interface PostOgImageProps { + title: string; + description: string; + categories?: string[]; +} + +/** Returns the JSX element for a per-guide-post Open Graph image. */ +export function getPostOgImageElement({ + title, + description, + categories = [], +}: PostOgImageProps): ReactElement { + return ( +
+
+
+
+ PackRat Guides +
+
+ +
+ {categories.length > 0 && ( +
+ {categories.slice(0, 3).map((cat) => ( +
+ {cat} +
+ ))} +
+ )} +
50 ? '44px' : '56px', + fontWeight: 700, + color: 'white', + lineHeight: 1.15, + letterSpacing: '-1px', + maxWidth: '900px', + }} + > + {title} +
+
+ {description.length > 120 ? `${description.slice(0, 117)}...` : description} +
+
+ +
+ guides.packratai.com +
+
+ ); +} diff --git a/apps/guides/package.json b/apps/guides/package.json index b0331cc335..7c3ae05e6d 100644 --- a/apps/guides/package.json +++ b/apps/guides/package.json @@ -3,17 +3,19 @@ "version": "2.0.25", "private": true, "scripts": { - "build": "bun run build-content && next build", + "build": "bun run generate-og-images && bun run build-content && next build", "build-content": "bun run scripts/build-content.ts", "clean": "bunx rimraf .next node_modules out", "demo-enhancement": "bun run scripts/demo-enhancement.ts", "dev": "next dev", "doctor:react": "bunx react-doctor", "enhance-content": "bun run scripts/enhance-content.ts", + "generate-og-images": "bun run scripts/generate-og-images.ts", "lighthouse": "bun run build && bunx lhci autorun", "lint": "next lint", "start": "next start", "sync-to-r2": "bun run scripts/sync-to-r2.ts", + "test": "vitest run --config vitest.config.ts", "test-enhancement": "bun run scripts/test-enhancement.ts", "update-authors": "bun run scripts/update-authors.ts" }, @@ -96,6 +98,7 @@ "postcss": "^8.5.6", "postcss-import": "^16.1.1", "tailwindcss": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "~3.1.4" } } diff --git a/apps/guides/scripts/generate-og-images.ts b/apps/guides/scripts/generate-og-images.ts new file mode 100644 index 0000000000..104bd96ed6 --- /dev/null +++ b/apps/guides/scripts/generate-og-images.ts @@ -0,0 +1,67 @@ +/** + * Pre-build script: generates static Open Graph image PNGs for the guides site. + * + * Static exports (`output: 'export'`) cannot serve Next.js metadata-route images + * (opengraph-image.tsx) correctly from a CDN — the generated .body/.meta files + * are a Next.js-internal format, not plain PNG files. + * + * This script renders the same JSX used in opengraph-image.tsx via ImageResponse + * and writes real .png files to public/ so Cloudflare Workers can serve them with + * the correct Content-Type automatically. + * + * Outputs: + * public/og-image.png — root / site-level OG image + * public/og/[slug].png — per-post OG images + * + * Run: `bun run scripts/generate-og-images.ts` + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { ImageResponse } from 'next/og'; +import { createElement } from 'react'; +import { getAllPosts } from '../lib/mdx-static'; +import { getGuidesOgImageElement, getPostOgImageElement, OG_IMAGE_SIZE } from '../lib/og-image'; + +const PUBLIC_DIR = path.join(import.meta.dir, '..', 'public'); +const OG_DIR = path.join(PUBLIC_DIR, 'og'); + +async function renderToPng(element: ReturnType): Promise { + const response = new ImageResponse( + createElement(() => element), + OG_IMAGE_SIZE, + ); + return Buffer.from(await response.arrayBuffer()); +} + +async function generateOgImages(): Promise { + fs.mkdirSync(OG_DIR, { recursive: true }); + + // Root site image + const rootBuffer = await renderToPng(getGuidesOgImageElement()); + const rootPath = path.join(PUBLIC_DIR, 'og-image.png'); + fs.writeFileSync(rootPath, rootBuffer); + console.log(`✓ Generated ${path.relative(process.cwd(), rootPath)} (${rootBuffer.length} bytes)`); + + // Per-post images + const posts = getAllPosts(); + for (const post of posts) { + const buffer = await renderToPng( + getPostOgImageElement({ + title: post.title, + description: post.description ?? '', + categories: post.categories ?? [], + }), + ); + const outPath = path.join(OG_DIR, `${post.slug}.png`); + fs.writeFileSync(outPath, buffer); + console.log(`✓ Generated ${path.relative(process.cwd(), outPath)} (${buffer.length} bytes)`); + } + + console.log(`\nDone — generated 1 root + ${posts.length} post OG images.`); +} + +generateOgImages().catch((err) => { + console.error('Failed to generate OG images:', err); + process.exit(1); +}); diff --git a/apps/guides/vitest.config.ts b/apps/guides/vitest.config.ts new file mode 100644 index 0000000000..7eb8b7863d --- /dev/null +++ b/apps/guides/vitest.config.ts @@ -0,0 +1,18 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + 'guides-app': resolve(__dirname, '.'), + }, + }, + test: { + name: 'guides-og', + environment: 'node', + globals: true, + include: [resolve(__dirname, '__tests__/**/*.test.ts')], + hookTimeout: 60_000, + testTimeout: 15_000, + }, +}); diff --git a/apps/landing/__tests__/og-image.test.ts b/apps/landing/__tests__/og-image.test.ts new file mode 100644 index 0000000000..1d595a1552 --- /dev/null +++ b/apps/landing/__tests__/og-image.test.ts @@ -0,0 +1,62 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { landingMetadata } from '../lib/metadata'; + +const OG_IMAGE_PATH = path.resolve(__dirname, '../public/og-image.png'); +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** Read a uint32 big-endian from a buffer at offset. */ +function readUint32BE(buf: Buffer, offset: number): number { + return buf.readUInt32BE(offset); +} + +describe('landing OG image generation', () => { + beforeAll(() => { + execSync('bun run scripts/generate-og-images.ts', { + cwd: path.resolve(__dirname, '..'), + stdio: 'inherit', + }); + }); + + it('generates public/og-image.png', () => { + expect(fs.existsSync(OG_IMAGE_PATH)).toBe(true); + }); + + it('output is a valid PNG file', () => { + const buf = fs.readFileSync(OG_IMAGE_PATH); + expect(buf.subarray(0, 8)).toEqual(PNG_SIGNATURE); + }); + + it('PNG has correct dimensions (1200 × 630)', () => { + const buf = fs.readFileSync(OG_IMAGE_PATH); + // IHDR chunk starts at byte 16; first 4 bytes = width, next 4 = height + const width = readUint32BE(buf, 16); + const height = readUint32BE(buf, 20); + expect(width).toBe(1200); + expect(height).toBe(630); + }); + + it('PNG is non-trivially sized (> 1 KB)', () => { + const { size } = fs.statSync(OG_IMAGE_PATH); + expect(size).toBeGreaterThan(1024); + }); +}); + +describe('landing metadata', () => { + it('includes openGraph.images pointing to /og-image.png', () => { + const images = (landingMetadata.openGraph as { images?: unknown })?.images; + expect(images).toBeDefined(); + const first = Array.isArray(images) ? images[0] : images; + const url = typeof first === 'string' ? first : (first as { url: string })?.url; + expect(url).toBe('/og-image.png'); + }); + + it('includes twitter.images pointing to /og-image.png', () => { + const images = (landingMetadata.twitter as { images?: unknown })?.images; + expect(images).toBeDefined(); + const first = Array.isArray(images) ? images[0] : images; + expect(first).toBe('/og-image.png'); + }); +}); diff --git a/apps/landing/app/layout.tsx b/apps/landing/app/layout.tsx index beb509206a..2324e29eb7 100644 --- a/apps/landing/app/layout.tsx +++ b/apps/landing/app/layout.tsx @@ -2,8 +2,7 @@ import { cn } from '@packrat/web-ui/lib/utils'; import MainNav from 'landing-app/components/main-nav'; import SiteFooter from 'landing-app/components/site-footer'; import { ThemeProvider } from 'landing-app/components/theme-provider'; -import { siteConfig } from 'landing-app/config/site'; -import type { Metadata } from 'next'; +import { landingMetadata } from 'landing-app/lib/metadata'; import { Mona_Sans as FontSans } from 'next/font/google'; import type React from 'react'; import './globals.css'; @@ -14,37 +13,7 @@ const fontSans = FontSans({ weight: ['400', '500', '600', '700'], }); -export const metadata: Metadata = { - title: { - default: siteConfig.name, - template: `%s | ${siteConfig.name}`, - }, - description: siteConfig.description, - keywords: siteConfig.keywords, - authors: [{ name: siteConfig.author, url: siteConfig.url }], - creator: siteConfig.author, - metadataBase: new URL(siteConfig.url), - openGraph: { - type: 'website', - locale: 'en_US', - url: siteConfig.url, - title: siteConfig.name, - description: siteConfig.description, - siteName: siteConfig.name, - }, - twitter: { - card: 'summary_large_image', - title: siteConfig.name, - description: siteConfig.description, - creator: siteConfig.twitterHandle, - }, - icons: { - icon: '/PackRat.ico', - shortcut: '/favicon-16x16.png', - apple: '/apple-touch-icon.png', - }, - manifest: `${siteConfig.url}/site.webmanifest`, -}; +export const metadata = landingMetadata; export default function RootLayout({ children, diff --git a/apps/landing/app/opengraph-image.tsx b/apps/landing/app/opengraph-image.tsx index f4ed493119..e79b53bca3 100644 --- a/apps/landing/app/opengraph-image.tsx +++ b/apps/landing/app/opengraph-image.tsx @@ -1,90 +1,14 @@ +import { + getLandingOgImageElement, + OG_IMAGE_CONTENT_TYPE, + OG_IMAGE_SIZE, +} from 'landing-app/lib/og-image'; import { ImageResponse } from 'next/og'; export const dynamic = 'force-static'; -export const size = { width: 1200, height: 630 }; -export const contentType = 'image/png'; +export const size = OG_IMAGE_SIZE; +export const contentType = OG_IMAGE_CONTENT_TYPE; export default function Image() { - return new ImageResponse( -
-
-
- 🎒 -
-
- PackRat -
-
-
- Stop overpacking. Start adventuring. -
-
- {['10K+ Users', '4.8★ Rating', '100% Free'].map((stat) => ( -
- {stat} -
- ))} -
-
, - { ...size }, - ); + return new ImageResponse(getLandingOgImageElement(), { ...size }); } diff --git a/apps/landing/app/twitter-image.tsx b/apps/landing/app/twitter-image.tsx index c105da691f..e79b53bca3 100644 --- a/apps/landing/app/twitter-image.tsx +++ b/apps/landing/app/twitter-image.tsx @@ -1,70 +1,14 @@ +import { + getLandingOgImageElement, + OG_IMAGE_CONTENT_TYPE, + OG_IMAGE_SIZE, +} from 'landing-app/lib/og-image'; import { ImageResponse } from 'next/og'; export const dynamic = 'force-static'; -export const size = { width: 1200, height: 630 }; -export const contentType = 'image/png'; +export const size = OG_IMAGE_SIZE; +export const contentType = OG_IMAGE_CONTENT_TYPE; export default function Image() { - return new ImageResponse( -
-
-
- 🎒 -
-
- PackRat -
-
-
- Your AI-powered outdoor adventure companion. Free forever. -
-
, - { ...size }, - ); + return new ImageResponse(getLandingOgImageElement(), { ...size }); } diff --git a/apps/landing/lib/metadata.ts b/apps/landing/lib/metadata.ts new file mode 100644 index 0000000000..7dce150a79 --- /dev/null +++ b/apps/landing/lib/metadata.ts @@ -0,0 +1,36 @@ +import { siteConfig } from 'landing-app/config/site'; +import type { Metadata } from 'next'; + +export const landingMetadata: Metadata = { + title: { + default: siteConfig.name, + template: `%s | ${siteConfig.name}`, + }, + description: siteConfig.description, + keywords: siteConfig.keywords, + authors: [{ name: siteConfig.author, url: siteConfig.url }], + creator: siteConfig.author, + metadataBase: new URL(siteConfig.url), + openGraph: { + type: 'website', + locale: 'en_US', + url: siteConfig.url, + title: siteConfig.name, + description: siteConfig.description, + siteName: siteConfig.name, + images: [{ url: '/og-image.png', width: 1200, height: 630, alt: siteConfig.name }], + }, + twitter: { + card: 'summary_large_image', + title: siteConfig.name, + description: siteConfig.description, + creator: siteConfig.twitterHandle, + images: ['/og-image.png'], + }, + icons: { + icon: '/PackRat.ico', + shortcut: '/favicon-16x16.png', + apple: '/apple-touch-icon.png', + }, + manifest: `${siteConfig.url}/site.webmanifest`, +}; diff --git a/apps/landing/lib/og-image.tsx b/apps/landing/lib/og-image.tsx new file mode 100644 index 0000000000..f60dab071b --- /dev/null +++ b/apps/landing/lib/og-image.tsx @@ -0,0 +1,96 @@ +import type { ReactElement } from 'react'; + +export const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const; +export const OG_IMAGE_CONTENT_TYPE = 'image/png' as const; + +/** Returns the JSX element for the landing Open Graph / Twitter card image. */ +export function getLandingOgImageElement(): ReactElement { + return ( +
+
+
+
+
+
+ PackRat +
+
+
+ Stop overpacking. Start adventuring. +
+
+ {['10K+ Users', '4.8★ Rating', '100% Free'].map((stat) => ( +
+ {stat} +
+ ))} +
+
+ ); +} diff --git a/apps/landing/package.json b/apps/landing/package.json index e2cbe350c5..50886e76d3 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -3,13 +3,15 @@ "version": "2.0.25", "private": true, "scripts": { - "build": "next build", + "build": "bun run generate-og-images && next build", "clean": "bunx rimraf node_modules .next out", "dev": "next dev", "doctor:react": "bunx react-doctor", + "generate-og-images": "bun run scripts/generate-og-images.ts", "lighthouse": "bun run build && bunx lhci autorun", "lint": "next lint", - "start": "next start" + "start": "next start", + "test": "vitest run --config vitest.config.ts" }, "dependencies": { "@emotion/is-prop-valid": "^1.3.1", @@ -72,6 +74,7 @@ "postcss": "^8.5.6", "postcss-import": "^16.1.1", "tailwindcss": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "~3.1.4" } } diff --git a/apps/landing/scripts/generate-og-images.ts b/apps/landing/scripts/generate-og-images.ts new file mode 100644 index 0000000000..8af8ba5671 --- /dev/null +++ b/apps/landing/scripts/generate-og-images.ts @@ -0,0 +1,44 @@ +/** + * Pre-build script: generates static Open Graph image PNGs for the landing site. + * + * Static exports (`output: 'export'`) cannot serve Next.js metadata-route images + * (opengraph-image.tsx) correctly from a CDN — the generated .body/.meta files + * are a Next.js-internal format, not plain PNG files. + * + * This script renders the same JSX used in opengraph-image.tsx via ImageResponse + * and writes a real .png file to public/ so Cloudflare Workers can serve it with + * the correct Content-Type automatically. + * + * Run: `bun run scripts/generate-og-images.ts` + * Output: apps/landing/public/og-image.png + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { ImageResponse } from 'next/og'; +import { createElement } from 'react'; +import { getLandingOgImageElement, OG_IMAGE_SIZE } from '../lib/og-image'; + +const PUBLIC_DIR = path.join(import.meta.dir, '..', 'public'); + +async function generateOgImages(): Promise { + if (!fs.existsSync(PUBLIC_DIR)) { + fs.mkdirSync(PUBLIC_DIR, { recursive: true }); + } + + const response = new ImageResponse( + createElement(() => getLandingOgImageElement()), + OG_IMAGE_SIZE, + ); + + const buffer = Buffer.from(await response.arrayBuffer()); + const outputPath = path.join(PUBLIC_DIR, 'og-image.png'); + fs.writeFileSync(outputPath, buffer); + + console.log(`✓ Generated ${path.relative(process.cwd(), outputPath)} (${buffer.length} bytes)`); +} + +generateOgImages().catch((err) => { + console.error('Failed to generate OG images:', err); + process.exit(1); +}); diff --git a/apps/landing/vitest.config.ts b/apps/landing/vitest.config.ts new file mode 100644 index 0000000000..859d8c078b --- /dev/null +++ b/apps/landing/vitest.config.ts @@ -0,0 +1,16 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + 'landing-app': resolve(__dirname, '.'), + }, + }, + test: { + name: 'landing-og', + environment: 'node', + globals: true, + include: [resolve(__dirname, '__tests__/**/*.test.ts')], + }, +}); diff --git a/bun.lock b/bun.lock index cfb54dacbe..aaa5027be7 100644 --- a/bun.lock +++ b/bun.lock @@ -279,6 +279,7 @@ "postcss-import": "^16.1.1", "tailwindcss": "catalog:", "typescript": "catalog:", + "vitest": "~3.1.4", }, }, "apps/landing": { @@ -346,6 +347,7 @@ "postcss-import": "^16.1.1", "tailwindcss": "catalog:", "typescript": "catalog:", + "vitest": "~3.1.4", }, }, "apps/trails": { @@ -4657,6 +4659,8 @@ "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@packrat/api/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], "@poppinss/colors/kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -5333,6 +5337,8 @@ "@manypkg/tools/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@packrat/api/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "@react-native-ai/apple/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@react-native-ai/llama/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], diff --git a/package.json b/package.json index 77d64f21a6..9bb4facf0f 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "test:e2e:ios": "bash .github/scripts/e2e.sh ios", "test:expo": "vitest run --config apps/expo/vitest.config.ts", "test:expo:rpc-types": "vitest run --config apps/expo/vitest.types.config.ts", + "test:guides": "vitest run --config apps/guides/vitest.config.ts", + "test:landing": "vitest run --config apps/landing/vitest.config.ts", "test:mcp": "bun run --cwd packages/mcp test", "trails": "bun run --cwd apps/trails dev", "web": "bun run --cwd apps/web dev"