Skip to content

feat(plugin): OpenGraph — auto-generate social cards from gallery images #5

@kirkone

Description

@kirkone

Goal

Auto-generate per-page social cards (1200×630, OG-format) from gallery cover images and ship them with the right OpenGraph meta tags so links shared on Discord, Slack, Mastodon, Bluesky, LinkedIn, iMessage, WhatsApp, Facebook, etc. show a real preview — without the user having to know what OpenGraph is.

Why a plugin (not part of Lumina or core)

We tried the theme-only path first (commit 2470279, reverted). It revealed:

  • The hard part isn't the meta tags — it's producing a usable image.
  • Photographer galleries use 16:9 hero / portrait covers. OG wants 1.91:1 (1200×630). Just pointing og:image at an existing variant produces ugly previews on every platform.
  • Asking the user to provide a hand-crafted card image (og_image URL in site.json) defeats the "out of the box" benefit and isn't realistic for the target audience.
  • Theme-only meta tags without a real image source had no value — and would have required template logic users couldn't reasonably understand.

A plugin can do what's needed: read the cover image, smart-crop to 1200×630 (NetVips supports saliency/face detection), write output/og/<slug>.jpg, and emit the right <meta> tags. Zero user configuration.

Plugin Design

Package: Spectara.Revela.Plugins.OpenGraph

Pipeline integration:

  • Reads manifest after generate scan
  • Picks an image per page (cover → first gallery image → site-wide fallback)
  • Smart-crops to 1200×630 with NetVips
  • Writes output/og/<gallery-slug>.jpg
  • Hooks into the render pipeline to inject <meta property="og:*"> tags into the rendered HTML

Generated tags (per page):

<meta property="og:type"        content="article">           <!-- "website" for home -->
<meta property="og:title"       content="Page or site title">
<meta property="og:description" content="Page or site description">
<meta property="og:image"       content="https://example.com/og/vacation.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url"         content="https://example.com/vacation/">
<meta property="og:site_name"   content="Site name">
<meta property="og:locale"      content="en_US">

Behaviour gates (mirrors sitemap pattern):

  • baseUrl not set in project.json → plugin emits info log, generates nothing (absolute URLs are required by every consumer)
  • No image candidate (no cover, no first image, no site fallback) → no og:image, other tags still emitted
  • HTML hook only injects when no <meta property="og: already exists in <head> (lets advanced users override)

Twitter Cards (twitter:*) intentionally out of scope — every modern platform reads OpenGraph, the twitter:* spec is effectively legacy since X stopped maintaining its public dev resources. Users who want them can ship a tiny override theme partial.

Optional later (separate issue): Branded card variants with text overlay (gallery title + site name) à la Vercel/GitHub OG cards — needs font handling, layout templating.

Pipeline Hook Question (open)

The plugin needs to inject tags into rendered HTML. Two approaches:

  1. Post-process HTML — read each index.html after generate pages, inject before </head>. Simple, no template engine coupling. Robust against any theme.
  2. Inject template variables — add og_* variables to the layout model so themes can {{ include 'opengraph' }}. Couples plugin to theme convention.

Lean toward (1) — it works for any theme without theme cooperation. Plugin stays self-contained.

Lessons Learned (from the reverted attempt)

  • current_url / base_url template variables: useful concept, but only added when a real consumer needs them. The plugin will be that consumer if it goes route (2).
  • BaseUrl was string with fallback "https://example.com" — the if (config.Project.BaseUrl is not null) gate at the sitemap site was dead code. Will be fixed in a separate, isolated commit so sitemap correctly skips when baseUrl is missing (matches the docs).
  • Per-page is the right model (not site-wide) — different shares show different previews. This is what every modern SSG does (Hugo .Permalink, Zola current_url, Jekyll absolute_url).
  • Image-format mismatch is the real problem to solve; everything else is plumbing.

Implementation Tasks

  • Spec + design doc in docs/plugins/opengraph.md
  • New plugin project src/Plugins/OpenGraph/
  • Smart-crop image generator (NetVips Image.Smartcrop() with attention strategy)
  • Pipeline integration (post-render HTML injection)
  • Per-page image candidate selection (cover → first image → site fallback)
  • Tests (unit: candidate selection, integration: full pipeline with real JPEGs)
  • Sample project demonstrating the result
  • Validate with https://www.opengraph.xyz/ and real Discord/Slack share

Out of Scope (for this issue)

  • Twitter Cards
  • Branded text-overlay cards
  • Per-page og_image frontmatter override (can add later when needed)
  • AI-generated card layouts

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions