feat(site): add optional OG image support to blog posts (#878)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-03-10 19:11:06 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 57ab4b2cdb
commit 156986abb9
17 changed files with 82 additions and 154 deletions
+7
View File
@@ -421,9 +421,16 @@ All content must be written in **MDX format** to support:
authors: string[]; // Reference to authors.json
canonical?: string; // Canonical URL override
devOnly?: boolean; // Show only in development
ogImage?: ImageMetadata | string; // Local image or external URL
twitterImage?: ImageMetadata | string; // Falls back to ogImage
}
```
**OG images** go in `src/assets/blog/{date-slug}/og.png`. Reference from frontmatter with a relative path:
```yaml
ogImage: '../../assets/blog/2026-03-10-my-post/og.png'
```
### Docs Collection (`src/content/docs/`)
**Subdirectories:**
+8 -1
View File
@@ -18,7 +18,14 @@ import remarkConditionalHeadings from './src/utils/remarkConditionalHeadings';
import { remarkReadingTime } from './src/utils/remarkReadingTime.mjs';
import shikiTransformMetadata from './src/utils/shikiTransformMetadata';
const SITE_URL = 'https://videojs.org';
// Astro docs say `site` should be "your final, deployed URL", but we override
// it per-deploy so that generated absolute URLs (OG images, RSS, etc.) resolve
// correctly on Netlify deploy previews. On production, DEPLOY_PRIME_URL is the
// primary site URL so it's equivalent.
//
// For URLs that must always point to production regardless of deploy context
// (e.g. canonical, JSON-LD), use PRODUCTION_URL from src/consts.ts instead.
const SITE_URL = process.env.DEPLOY_PRIME_URL || 'https://videojs.org';
// https://astro.build/config
export default defineConfig({
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+4
View File
@@ -1,3 +1,7 @@
// Always https://videojs.org. Unlike Astro.site, which varies per deploy
// (e.g. deploy preview URLs), this is stable for canonical URLs and other
// references that must always point to production.
export const PRODUCTION_URL = new URL('https://videojs.org');
export const SITE_TITLE = 'Video.js';
export const SEO_SUFFIX = 'Open Source Video Player';
export const SITE_DESCRIPTION = `The open-source video player for React and HTML. Lightweight, accessible components built for performance and streaming.`;
+3 -1
View File
@@ -49,7 +49,7 @@ const blog = defineCollection({
},
}),
// Type-check frontmatter using a schema
schema: () =>
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
@@ -58,6 +58,8 @@ const blog = defineCollection({
authors: z.array(reference('authors')),
canonical: z.string().url().optional(),
devOnly: z.boolean().optional(), // only visible in development mode
ogImage: image().or(z.string().url()).optional(),
twitterImage: image().or(z.string().url()).optional(),
}),
});
@@ -2,6 +2,7 @@
title: 'Video.js v10 Beta: Hello, World (again)'
description: "Video.js v10 is a ground-up rewrite combining four open source players into one modern framework — 88% smaller default bundles, first-class React and TypeScript support, composable architecture, and beautiful new skins designed by Plyr's creator."
authors: [steve-heffernan]
ogImage: '../../assets/blog/2026-03-10-videojs-v10-beta-hello-world-again/og.png'
---
Today we're excited to release the Video.js v10.0.0 beta. It's the result of a rather large ground-up rewrite, not just of Video.js ([discussion](https://github.com/videojs/video.js/discussions/9035)) but also of [Plyr](https://github.com/sampotts/plyr/discussions/2871), [Vidstack](https://github.com/vidstack/player/discussions/1747), and [Media Chrome](https://www.mux.com/blog/from-media-chrome-to-video-js-v10-the-evolution-of-html-first-video-players), through a rare teaming-up of open source projects and people who care a lot about web video, with a combined 75,000 github stars and tens of billions of video plays monthly.
@@ -3,6 +3,7 @@ title: 'Blog Component Test'
description: 'A dev-only blog post for testing blog components and MDX features'
authors: [darius-cepulis]
devOnly: true
ogImage: '../../assets/blog/2099-12-31-blog-component-test/og.png'
---
import Aside from '@/components/Aside.astro'
File diff suppressed because one or more lines are too long
+23 -13
View File
@@ -2,7 +2,7 @@
import { Font } from 'astro:assets';
import type { ImageMetadata } from 'astro';
import { SEO_SUFFIX, SITE_TITLE } from '@/consts';
import { PRODUCTION_URL, SEO_SUFFIX, SITE_TITLE } from '@/consts';
import '@/styles/globals.css';
@@ -14,14 +14,22 @@ import ThemeInit from '@/components/ThemeInit.astro';
interface Props {
title: string | string[];
description: string;
image?: ImageMetadata;
image?: ImageMetadata | string;
twitterImage?: ImageMetadata | string;
canonical?: string;
suffix?: string;
}
const { canonical, suffix } = Astro.props;
const canonicalURL = canonical || new URL(Astro.url.pathname, Astro.site);
const canonicalURL = canonical || new URL(Astro.url.pathname, PRODUCTION_URL);
const { title, description, image } = Astro.props;
const { title, description, image, twitterImage } = Astro.props;
function resolveImageUrl(img: ImageMetadata | string): string {
if (typeof img === 'string') return img;
return new URL(img.src, Astro.url).toString();
}
const resolvedTwitterImage = twitterImage ?? image;
const seoSuffix = suffix ?? SEO_SUFFIX;
const pageTitle = Array.isArray(title) ? title.join(' | ') : title;
@@ -109,25 +117,27 @@ const fullTitle =
{/* Open Graph / Facebook */}
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta
property="og:url"
content={new URL(Astro.url.pathname, PRODUCTION_URL)}
/>
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
{
image && (
<meta property="og:image" content={new URL(image.src, Astro.url)} />
)
}
{image && <meta property="og:image" content={resolveImageUrl(image)} />}
{/* Twitter */}
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta
property="twitter:url"
content={new URL(Astro.url.pathname, PRODUCTION_URL)}
/>
<meta property="twitter:title" content={fullTitle} />
<meta property="twitter:description" content={description} />
{
image && (
resolvedTwitterImage && (
<meta
property="twitter:image"
content={new URL(image.src, Astro.url)}
content={resolveImageUrl(resolvedTwitterImage)}
/>
)
}
+11 -2
View File
@@ -1,5 +1,6 @@
---
import type { ImageMetadata } from 'astro';
import Footer from '@/components/Footer.astro';
import FooterEasterEgg from '@/components/FooterEasterEgg.astro';
import NavBar from '@/components/NavBar/NavBar.astro';
@@ -9,12 +10,20 @@ interface Props {
title: string | string[];
description: string;
canonical?: string;
image?: ImageMetadata | string;
twitterImage?: ImageMetadata | string;
}
const { title, description, canonical } = Astro.props;
const { title, description, canonical, image, twitterImage } = Astro.props;
---
<Base title={title} description={description} canonical={canonical}>
<Base
title={title}
description={description}
canonical={canonical}
image={image}
twitterImage={twitterImage}
>
<slot name="head" slot="head" />
<div class="flex min-h-screen flex-col">
<NavBar />
+3 -2
View File
@@ -7,6 +7,7 @@ import BlogPagination from '@/components/blog/BlogPagination.astro';
import BlogPostCard from '@/components/blog/BlogPostCard.astro';
import FooterEasterEgg from '@/components/FooterEasterEgg.astro';
import JsonLd from '@/components/JsonLd.astro';
import { PRODUCTION_URL } from '@/consts';
import Blog from '@/layouts/Blog.astro';
import { createBlogCollectionSchema } from '@/utils/jsonLd/schemas';
@@ -37,11 +38,11 @@ interface Props {
const { page } = Astro.props;
// Build JSON-LD schema for CollectionPage
const pageUrl = new URL(Astro.url.pathname, Astro.site).toString();
const pageUrl = new URL(Astro.url.pathname, PRODUCTION_URL).toString();
const jsonLdSchema = createBlogCollectionSchema({
url: pageUrl,
posts: page.data,
siteUrl: Astro.site!.toString(),
siteUrl: PRODUCTION_URL.toString(),
});
---
+13 -2
View File
@@ -5,6 +5,7 @@ import { getCollection, getEntries, render } from 'astro:content';
import FormattedDate from '@/components/FormattedDate.astro';
import JsonLd from '@/components/JsonLd.astro';
import defaultMarkdownComponents from '@/components/typography/defaultMarkdownComponents';
import { PRODUCTION_URL } from '@/consts';
import Blog from '@/layouts/Blog.astro';
import { createBlogPostingSchema } from '@/utils/jsonLd/schemas';
@@ -23,8 +24,15 @@ const post = Astro.props;
const { Content, remarkPluginFrontmatter } = await render(post);
const authors = await getEntries(post.data.authors);
// Resolve OG image URL for JSON-LD
const ogImageUrl = post.data.ogImage
? typeof post.data.ogImage === 'string'
? post.data.ogImage
: new URL(post.data.ogImage.src, Astro.url).toString()
: undefined;
// Build JSON-LD schema for BlogPosting
const pageUrl = new URL(Astro.url.pathname, Astro.site).toString();
const pageUrl = new URL(Astro.url.pathname, PRODUCTION_URL).toString();
const jsonLdSchema = createBlogPostingSchema({
title: post.data.title,
description: post.data.description,
@@ -33,7 +41,8 @@ const jsonLdSchema = createBlogPostingSchema({
updatedDate: post.data.updatedDate,
readingTime: remarkPluginFrontmatter.readingTimeMinutes,
authors,
siteUrl: Astro.site!.toString(),
siteUrl: PRODUCTION_URL.toString(),
image: ogImageUrl,
});
---
@@ -41,6 +50,8 @@ const jsonLdSchema = createBlogPostingSchema({
title={[post.data.title, "Blog"]}
description={post.data.description}
canonical={post.data.canonical}
image={post.data.ogImage}
twitterImage={post.data.twitterImage}
>
<JsonLd slot="head" schema={jsonLdSchema} />
<article
+3 -2
View File
@@ -5,6 +5,7 @@ import { getCollection, getEntries } from 'astro:content';
import { AuthorSocialLinks } from '@/components/blog/AuthorSocialLinks';
import BlogPostCard from '@/components/blog/BlogPostCard.astro';
import JsonLd from '@/components/JsonLd.astro';
import { PRODUCTION_URL } from '@/consts';
import Blog from '@/layouts/Blog.astro';
import { createProfilePageSchema } from '@/utils/jsonLd/schemas';
@@ -36,12 +37,12 @@ const postsWithAuthors = await Promise.all(
);
// Build JSON-LD schema for ProfilePage
const pageUrl = new URL(Astro.url.pathname, Astro.site).toString();
const pageUrl = new URL(Astro.url.pathname, PRODUCTION_URL).toString();
const jsonLdSchema = createProfilePageSchema({
url: pageUrl,
author,
posts: authorPosts,
siteUrl: Astro.site!.toString(),
siteUrl: PRODUCTION_URL.toString(),
});
---
@@ -10,6 +10,7 @@ import { TableOfContents } from '@/components/docs/TableOfContents';
import JsonLd from '@/components/JsonLd.astro';
import defaultMarkdownComponents from '@/components/typography/defaultMarkdownComponents';
import H2Markdown from '@/components/typography/H2Markdown.astro';
import { PRODUCTION_URL } from '@/consts';
import DocsLayout from '@/layouts/Docs.astro';
import type { SupportedFramework } from '@/types/docs';
import { SUPPORTED_FRAMEWORKS } from '@/types/docs';
@@ -81,7 +82,7 @@ const docTitles = new Map(allDocs.map((d) => [d.id, getDocTitle(d, framework)]))
const sections = getSectionsForGuide(slug, filterSidebar(framework));
// Build JSON-LD schema for TechArticle
const pageUrl = new URL(Astro.url.pathname, Astro.site).toString();
const pageUrl = new URL(Astro.url.pathname, PRODUCTION_URL).toString();
const jsonLdSchema = createTechArticleSchema({
title: getDocTitle(doc, framework),
description: doc.data.description,
+2
View File
@@ -63,6 +63,7 @@ export function createBlogPostingSchema(params: {
readingTime?: number;
authors: CollectionEntry<'authors'>[];
siteUrl: string;
image?: string;
}): WithContext<BlogPosting> {
return {
'@context': 'https://schema.org',
@@ -70,6 +71,7 @@ export function createBlogPostingSchema(params: {
headline: params.title,
description: params.description,
url: params.url,
...(params.image && { image: params.image }),
datePublished: params.pubDate.toISOString(),
...(params.updatedDate && { dateModified: params.updatedDate.toISOString() }),
...(params.wordCount && { wordCount: params.wordCount }),
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://turbo.build/schema.json",
"ui": "stream",
"concurrency": "20",
"globalEnv": ["DEPLOY_PRIME_URL"],
"tasks": {
"build": {
"dependsOn": ["^build"],