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 (
+
+
+
+ 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 (
+
+
+
+
+ {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(
-
-
-
- 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(
-
-
-
- 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 (
+
+
+
+ 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"