Skip to content

feat(www): add OG image generation for blog posts#1120

Closed
taras wants to merge 6 commits intov4from
blog-og-thumbnails
Closed

feat(www): add OG image generation for blog posts#1120
taras wants to merge 6 commits intov4from
blog-og-thumbnails

Conversation

@taras
Copy link
Copy Markdown
Member

@taras taras commented Feb 19, 2026

Motivation

Social media platforms (Twitter, LinkedIn, Facebook, etc.) don't support SVG images in Open Graph og:image meta tags. Our blog posts use SVG featured images that render beautifully on the blog but appear broken or missing when shared on social media.

This PR adds build-time PNG generation from SVG featured images so social previews work correctly.

Approach

  • PNG generation script: Uses Resvg WASM to render SVGs to 1200×630 PNGs (standard OG dimensions)
  • Effection structured concurrency: Uses bounded parallelism (4 concurrent) for efficient batch processing
  • Animation stripping: Removes CSS animations from animated SVGs to show "final state" where all content is visible
  • Light mode forcing: Uses cascade override injection (!important rules at end of SVG) to ensure consistent light mode colors regardless of system theme
  • TTF fonts committed: Inter and JetBrains Mono (OFL licensed) since Resvg WASM doesn't support WOFF2

Build & Deploy Flow

┌─────────────────────────────────────────────────────────────────────────────┐
│                            GitHub Actions CI                                 │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  1. Generate OG Images                                                      │
│     ┌──────────────────┐                                                    │
│     │ deno task        │     ┌─────────┐      ┌─────────┐                   │
│     │ generate-og-     │────▶│  SVG    │─────▶│  PNG    │                   │
│     │ images           │     │ (blog)  │      │ (1200×  │                   │
│     └──────────────────┘     └─────────┘      │  630)   │                   │
│            │                                   └─────────┘                   │
│            │ Resvg WASM                             │                        │
│            │ • Strip animations                     │                        │
│            │ • Force light mode                     │                        │
│            │ • Embed TTF fonts                      │                        │
│            ▼                                        ▼                        │
│  2. Start Dev Server                                                        │
│     ┌──────────────────┐                                                    │
│     │ deno task dev    │  serves both SVG and PNG via assetsRoute("blog")   │
│     │ localhost:8000   │                                                    │
│     └──────────────────┘                                                    │
│            │                                                                │
│            ▼                                                                │
│  3. Staticalize                                                             │
│     ┌──────────────────┐     ┌─────────────────────────────────────────┐    │
│     │ staticalize      │────▶│ Crawls sitemap.xml                      │    │
│     │ --site localhost │     │ For each page:                          │    │
│     │ --output built/  │     │   • Downloads HTML                      │    │
│     │ --base effection │     │   • Parses [content] attributes         │    │
│     │   .deno.dev      │     │   • Finds og:image meta tag URL         │    │
│     └──────────────────┘     │   • Downloads referenced PNG ◀────────┐ │    │
│            │                 │   • Rewrites URL to production base   │ │    │
│            │                 └───────────────────────────────────────┼─┘    │
│            │                                                         │      │
│            │                 <meta property="og:image"               │      │
│            │                   content="http://localhost:8000/       │      │
│            │                     blog/.../image.png">────────────────┘      │
│            ▼                                                                │
│  4. Deploy to Deno Deploy                                                   │
│     ┌──────────────────┐                                                    │
│     │ built/           │  Contains HTML + PNG files                         │
│     │ ├── blog/        │  og:image URLs rewritten to                        │
│     │ │   └── post/    │  https://effection.deno.dev/blog/.../image.png     │
│     │ │       └── .png │                                                    │
│     │ └── ...          │                                                    │
│     └──────────────────┘                                                    │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Result

When a blog post is shared on social media, the platform fetches the og:image URL and displays the PNG thumbnail instead of showing a broken image or generic fallback.

Generate PNG thumbnails from SVG featured images for Open Graph meta tags.
Social media platforms don't support SVG for og:image, so we render PNGs
at build time using Resvg WASM.

Key features:
- Strip CSS animations to show final visible state
- Force light mode for consistent social previews
- Cascade override injection for reliable text colors
- Bounded concurrency (4 parallel) using Effection
- TTF fonts committed (Inter, JetBrains Mono) for Resvg compatibility

The PNGs are generated during CI before staticalize runs, and staticalize
automatically discovers them via og:image meta tag content URLs.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Feb 19, 2026

Open in StackBlitz

npm i https://pkg.pr.new/thefrontside/effection@1120

commit: e0bfd7a

The code is self-documenting; inline comments were redundant.
Reduces file from ~230 lines to ~170 lines.
Copy link
Copy Markdown
Member

@cowboyd cowboyd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd try lazy generation first. It will be a less invasive change, and it means the website will work the same in local context as everywhere (no need to have a separate task at build time)

It also means that if generation isn't working, it will be caught during dev time before CI even runs

@cowboyd
Copy link
Copy Markdown
Member

cowboyd commented Feb 19, 2026

If there is principle here to extract, it is that what makes staticalize work is that the there is no build, only capture. The server is the build.

Replace build-time PNG generation with on-demand rendering via
Revolution plugin. PNGs are now generated when requested and cached
using the Web Cache API.

Changes:
- Add www/plugins/og-image.ts - intercepts /blog/**/*.png requests
- Use @effectionx/fs for Effection-native file operations
- Remove www/scripts/generate-og-images.ts build script
- Remove generate-og-images task from deno.json
- Remove CI step for pre-generating images

Benefits:
- Server IS the build (cowboyd's principle)
- Same behavior locally and in production
- Issues caught during dev before CI runs
- No separate build step needed
The PNG is generated on-demand by the og-image plugin, so we check
if the source SVG exists instead of the output PNG.
- Use @effectionx/fetch instead of native fetch for WASM download
- Make route detection generic (check if SVG exists instead of /blog/ prefix)
- Make transformSvg stateless using reduce pipeline
- Add documentation comment explaining transformSvg purpose
- Make stripAnimations use local variable instead of reassigning parameter
Comment thread www/plugins/og-image.ts
Comment on lines +106 to +107
console.error(`OG image generation failed for ${pathname}:`, error);
return yield* serveFallback(request, options.basedir);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this should be recoverable. It means we found an svg that should be rendered off of the png but that it failed, which means that there is a bug in our site.

let AppHtml = yield* useAppHtml({
title: `${post.title} | Blog | Effection`,
description: post.description,
ogImage: post.ogImage ? `/blog/${id}/${post.ogImage}` : undefined,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is where the fallback should go, not a const. This is detecting that there is actually no image associated with the blog post.

Comment thread www/resources/blog.ts
Comment on lines +137 to +146
// Compute OG image path: if SVG exists, the PNG will be generated on-demand
// Note: blog-post-route.tsx prepends /blog/{id}/ to this value
let ogImage: string | undefined;
if (frontmatter.image?.endsWith(".svg")) {
let svgFullPath = `${directory}${id}/${frontmatter.image}`;
if (existsSync(svgFullPath)) {
ogImage = frontmatter.image.replace(/\.svg$/, ".png");
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bleeds the plugin logic into the blog post, while at the same time duplicating it. not good.

The plugin should transform the html and do the check there. You could probably generate it at that point to, so the plugin is not an *http() plugin, but an *html().

@cowboyd
Copy link
Copy Markdown
Member

cowboyd commented Apr 16, 2026

This has been implemented.

@cowboyd cowboyd closed this Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants