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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
113 changes: 113 additions & 0 deletions apps/guides/__tests__/og-image.test.ts
Original file line number Diff line number Diff line change
@@ -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`);
});
});
105 changes: 12 additions & 93 deletions apps/guides/app/guide/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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 }));
Expand All @@ -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(
<div
style={{
background: 'linear-gradient(135deg, #1E3A5F 0%, #1a56a0 60%, #0284C7 100%)',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
fontFamily: 'system-ui, -apple-system, sans-serif',
padding: '64px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div style={{ fontSize: '28px' }}>🏔️</div>
<div
style={{
fontSize: '26px',
fontWeight: 600,
color: 'rgba(255,255,255,0.8)',
}}
>
PackRat Guides
</div>
</div>

<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{categories.length > 0 && (
<div style={{ display: 'flex', gap: '12px' }}>
{categories.slice(0, 3).map((cat) => (
<div
key={cat}
style={{
background: 'rgba(255,255,255,0.18)',
color: 'rgba(255,255,255,0.9)',
fontSize: '18px',
fontWeight: 500,
padding: '6px 16px',
borderRadius: '100px',
}}
>
{cat}
</div>
))}
</div>
)}
<div
style={{
fontSize: title.length > 50 ? '44px' : '56px',
fontWeight: 700,
color: 'white',
lineHeight: 1.15,
letterSpacing: '-1px',
maxWidth: '900px',
}}
>
{title}
</div>
<div
style={{
fontSize: '24px',
color: 'rgba(255,255,255,0.8)',
lineHeight: 1.4,
maxWidth: '820px',
}}
>
{description.length > 120 ? `${description.slice(0, 117)}...` : description}
</div>
</div>

<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
color: 'rgba(255,255,255,0.6)',
fontSize: '18px',
}}
>
guides.packratai.com
</div>
</div>,
getPostOgImageElement({
title: post?.title ?? 'PackRat Guides',
description: post?.description ?? 'Expert hiking and outdoor guides',
categories: post?.categories ?? [],
}),
{ ...size },
);
}
2 changes: 2 additions & 0 deletions apps/guides/app/guide/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`],
},
};
}
Expand Down
43 changes: 2 additions & 41 deletions apps/guides/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 (
Expand Down
Loading
Loading