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
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Optional in local development (falls back to http://localhost:3000)
# Set this in production to your canonical app URL, for example:
# NEXT_PUBLIC_SITE_URL=https://teacherbuddy.example.com
# Optional in local development.
# Set this in production to your canonical app URL:
# NEXT_PUBLIC_SITE_URL=
1 change: 1 addition & 0 deletions documentation/project-docs/metadata-and-seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ TeacherBuddy uses a static OG image asset:
- Declared dimensions: `895x372`

The shared OG and Twitter image path is defined once in `lib/metadata.ts`.
Social image tags are emitted as absolute URLs derived from `metadataBase` so crawlers can fetch them reliably.

## Environment Variables

Expand Down
10 changes: 6 additions & 4 deletions lib/__tests__/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ describe('resolveMetadataBase', () => {
);
});

it('falls back to localhost when no env URL values exist', () => {
expect(resolveMetadataBase().toString()).toBe('http://localhost:3000/');
it('falls back to production URL when no env URL values exist', () => {
expect(resolveMetadataBase().toString()).toBe(
'https://teacherbuddy.mrbubbles-src.dev/',
);
});
});

Expand All @@ -66,7 +68,7 @@ describe('buildPageMetadata', () => {
...SHARED_OPEN_GRAPH,
title: 'Student Management',
description: 'Add students, mark absences, and manage your roster.',
url: '/students',
url: 'https://teacherbuddy.mrbubbles-src.dev/students',
});
expect(metadata.twitter).toMatchObject({
...SHARED_TWITTER,
Expand All @@ -85,7 +87,7 @@ describe('buildPageMetadata', () => {
...SHARED_OPEN_GRAPH,
title: 'TeacherBuddy',
description: DEFAULT_SITE_DESCRIPTION,
url: '/missing',
url: 'https://teacherbuddy.mrbubbles-src.dev/missing',
});
expect(metadata.twitter).toMatchObject({
...SHARED_TWITTER,
Expand Down
26 changes: 18 additions & 8 deletions lib/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export const SITE_NAME = 'TeacherBuddy';
export const DEFAULT_SITE_DESCRIPTION =
'Manage students, build quizzes, and draw random pairs without repeats.';

const DEFAULT_SITE_URL = 'http://localhost:3000';
const DEFAULT_OG_IMAGE_PATH = '/images/og.png';
const PRODUCTION_SITE_URL = 'https://teacherbuddy.mrbubbles-src.dev';
const DEFAULT_SITE_URL = PRODUCTION_SITE_URL;
const DEFAULT_OG_IMAGE_PATH = '/api/og';
const URL_PROTOCOL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//;

/**
Expand All @@ -26,10 +27,10 @@ export const SHARED_OPEN_GRAPH: NonNullable<Metadata['openGraph']> = {
locale: 'en_US',
images: [
{
url: DEFAULT_OG_IMAGE_PATH,
width: 895,
height: 372,
alt: 'TeacherBuddy logo',
url: buildAbsoluteMetadataUrl(DEFAULT_OG_IMAGE_PATH),
width: 1200,
height: 630,
alt: 'TeacherBuddy Logo',
},
],
};
Expand All @@ -39,7 +40,7 @@ export const SHARED_OPEN_GRAPH: NonNullable<Metadata['openGraph']> = {
*/
export const SHARED_TWITTER: NonNullable<Metadata['twitter']> = {
card: 'summary_large_image',
images: [DEFAULT_OG_IMAGE_PATH],
images: [buildAbsoluteMetadataUrl(DEFAULT_OG_IMAGE_PATH)],
};

/**
Expand Down Expand Up @@ -71,6 +72,7 @@ export function buildPageMetadata(path: string): Metadata {
const title = routeMeta?.title ?? SITE_NAME;
const description = routeMeta?.description ?? DEFAULT_SITE_DESCRIPTION;
const canonicalPath = resolveCanonicalPath(path);
const absoluteCanonicalUrl = buildAbsoluteMetadataUrl(canonicalPath);

return {
title,
Expand All @@ -82,12 +84,13 @@ export function buildPageMetadata(path: string): Metadata {
...SHARED_OPEN_GRAPH,
title,
description,
url: canonicalPath,
url: absoluteCanonicalUrl,
},
twitter: {
...SHARED_TWITTER,
title,
description,
creator: '@_MstrBubbles',
},
};
}
Expand Down Expand Up @@ -133,3 +136,10 @@ function normalizeEnvUrl(
return null;
}
}

/**
* Resolves an absolute metadata URL from a path using the active site base.
*/
function buildAbsoluteMetadataUrl(path: string): string {
return new URL(path, resolveMetadataBase()).toString();
}
101 changes: 16 additions & 85 deletions lib/og-image.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/* eslint-disable @next/next/no-img-element */

import { ImageResponse } from 'next/og';

/**
* Accessible alt text used for Open Graph image metadata.
*/
export const OG_IMAGE_ALT = 'TeacherBuddy classroom dashboard preview';
export const OG_IMAGE_ALT = 'TeacherBuddy Logo';

/**
* Pixel dimensions used for the generated Open Graph image.
Expand All @@ -16,102 +18,31 @@ export const OG_IMAGE_SIZE = {
/**
* MIME type returned by the Open Graph image route.
*/
export const OG_IMAGE_CONTENT_TYPE = 'image/png';
export const OG_IMAGE_CONTENT_TYPE = 'image/svg+xml';

/**
* Generates the shared TeacherBuddy Open Graph image response.
*/
export function createOpenGraphImageResponse(): ImageResponse {
const imageUrl = 'https://teacherbuddy.mrbubbles-src.dev/logo-og.svg';

return new ImageResponse(
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
background:
'linear-gradient(135deg, rgb(17, 17, 27) 0%, rgb(30, 30, 46) 55%, rgb(49, 50, 68) 100%)',
color: 'rgb(205, 214, 244)',
padding: '72px',
fontFamily:
'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial',
justifyContent: 'space-between',
alignItems: 'stretch',
backgroundColor: 'rgb(15, 15, 28)',
alignItems: 'center',
justifyContent: 'center',
}}>
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
width: '70%',
}}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '14px',
fontSize: 32,
fontWeight: 600,
opacity: 0.96,
}}>
<div
style={{
display: 'flex',
width: 28,
height: 28,
borderRadius: '999px',
backgroundColor: 'rgb(245, 194, 231)',
}}
/>
TeacherBuddy
</div>

<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '18px',
marginBottom: '10px',
}}>
<div
style={{
display: 'flex',
fontSize: 78,
fontWeight: 800,
letterSpacing: '-0.03em',
lineHeight: 1.02,
color: 'rgb(242, 205, 205)',
}}>
Classroom Tools
</div>
<div
style={{
display: 'flex',
fontSize: 34,
lineHeight: 1.25,
color: 'rgb(166, 173, 200)',
}}>
Manage students, run quizzes, and organize class activities in one
place.
</div>
</div>
</div>

<div
style={{
display: 'flex',
width: 230,
borderRadius: 32,
background:
'linear-gradient(180deg, rgba(137, 180, 250, 0.35) 0%, rgba(137, 180, 250, 0.08) 100%)',
border: '1px solid rgba(205, 214, 244, 0.28)',
alignItems: 'center',
justifyContent: 'center',
fontSize: 28,
fontWeight: 600,
color: 'rgb(205, 214, 244)',
}}>
TeacherBuddy
</div>
<img
src={imageUrl}
alt={OG_IMAGE_ALT}
width={895}
height={372}
style={{ display: 'flex' }}
/>
</div>,
{
...OG_IMAGE_SIZE,
Expand Down
Binary file added public/logo-og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions public/logo-og.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading