Skip to content

feat(seo): enhance branded og:images with 2x3 grid and unified tagline#3173

Merged
MarkusNeusinger merged 6 commits intomainfrom
feat/og-image-branding-3172
Jan 6, 2026
Merged

feat(seo): enhance branded og:images with 2x3 grid and unified tagline#3173
MarkusNeusinger merged 6 commits intomainfrom
feat/og-image-branding-3172

Conversation

@MarkusNeusinger
Copy link
Copy Markdown
Owner

Summary

  • 2x3 Grid Collage: Changed spec overview og:image from 3 to 6 images in a 2x3 grid layout, selecting top implementations by quality_score
  • Unified Tagline: Consistent library-agnostic, ai-powered python plotting. across all branded images, meta tags, and og:image
  • Expanded Bot Detection: From 13 to 27 bots including Reddit, Mastodon, Microsoft Teams, Snapchat, Viber, and more
  • SEO Documentation: New docs/architecture/seo.md with complete architecture overview
  • Updated og-image.png: New design matching the unified tagline

Changes

File Description
api/routers/og_images.py Top 6 by quality_score for 2x3 grid
api/routers/seo.py Unified tagline
app/index.html Meta tags updated
app/nginx.conf 27 bots in 4 categories
app/public/og-image.png New design (compressed)
core/images.py 2x3 grid layout, new tagline
docs/architecture/seo.md NEW - SEO architecture docs
tests/unit/core/test_images.py Fix runpy warnings

Test plan

  • All 732 unit tests passing
  • No warnings
  • Ruff check/format passing
  • Test og:image generation with real spec
  • Validate with social media debuggers (Twitter, Facebook, LinkedIn)

Closes #3172

🤖 Generated with Claude Code

MarkusNeusinger and others added 2 commits January 6, 2026 10:06
- Add pyplots.ai branded header to implementation og:images
- Generate collage og:images for spec overview pages (2x2 grid)
- On-demand image generation with 1-hour caching
- New endpoints: /og/{spec_id}/{library}.png and /og/{spec_id}.png
- Update SEO proxy to use new branded image URLs
- Optimize og-image.png from 1.2MB to 453KB
- Add 8 unit tests for og_images router

Closes #3172

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Change collage layout from 3 images to 2x3 grid (6 images)
- Select top 6 implementations by quality_score for overview
- Unify tagline to "library-agnostic, ai-powered python plotting."
- Expand bot detection from 13 to 27 bots (Reddit, Mastodon, Teams, etc.)
- Add comprehensive SEO architecture documentation
- Update og-image.png with new design
- Fix test warnings for runpy module execution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings January 6, 2026 10:16
@codecov
Copy link
Copy Markdown

codecov bot commented Jan 6, 2026

Codecov Report

❌ Patch coverage is 26.64577% with 234 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
core/images.py 9.60% 226 Missing ⚠️
api/routers/og_images.py 87.30% 8 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR enhances SEO capabilities by introducing branded og:images with a 2x3 grid collage layout for spec overview pages, unifying the tagline across all branded assets to "library-agnostic, ai-powered python plotting.", and expanding bot detection from 13 to 27 user agents. A new comprehensive SEO architecture document has been added to aid future maintenance.

Key Changes

  • Branded OG Images: New /og/{spec_id}.png endpoint generates 2x3 grid collages of top 6 implementations by quality score, and /og/{spec_id}/{library}.png for individual branded implementation images
  • Unified Tagline: Simplified from "library-agnostic, ai-powered python plotting examples. automatically generated, tested, and maintained." to "library-agnostic, ai-powered python plotting." across all meta tags, README, and generated images
  • Expanded Bot Detection: nginx now detects 27 bots (up from 13) across 4 categories: Social Media, Messaging Apps, Search Engines, and Link Preview Services

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
api/routers/og_images.py New router with endpoints for generating branded og:images and collages with 1-hour caching
api/routers/seo.py Updated to use collage images for spec overview and branded images for implementations
api/routers/__init__.py Added og_images_router export
api/main.py Registered og_images_router
core/images.py Added branded og:image generation and 2x3 collage creation with pyplots.ai branding
app/nginx.conf Expanded bot detection from 13 to 27 user agents organized by category
app/index.html Updated meta tags with unified tagline
docs/architecture/seo.md New comprehensive SEO documentation covering bot detection, og:images, and sitemap
tests/unit/core/test_images.py Fixed runpy warning with module cache cleanup fixture
tests/unit/api/test_routers.py Added tests for new og_images router endpoints
README.md Updated tagline to match unified branding

Comment on lines +560 to +653
class TestOgImagesRouter:
"""Tests for OG image generation endpoints."""

def test_get_branded_impl_image_no_db(self, client: TestClient) -> None:
"""Should return 503 when DB not available."""
with patch(DB_CONFIG_PATCH, return_value=False):
response = client.get("/og/scatter-basic/matplotlib.png")
assert response.status_code == 503

def test_get_branded_impl_image_spec_not_found(self, db_client) -> None:
"""Should return 404 when spec not found."""
client, _ = db_client

mock_spec_repo = MagicMock()
mock_spec_repo.get_by_id = AsyncMock(return_value=None)

with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo):
response = client.get("/og/nonexistent/matplotlib.png")
assert response.status_code == 404

def test_get_branded_impl_image_impl_not_found(self, db_client, mock_spec) -> None:
"""Should return 404 when implementation not found."""
client, _ = db_client

mock_spec_repo = MagicMock()
mock_spec_repo.get_by_id = AsyncMock(return_value=mock_spec)

with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo):
# Request a library that doesn't exist in mock_spec
response = client.get("/og/scatter-basic/nonexistent.png")
assert response.status_code == 404

def test_get_branded_impl_image_cached(self, db_client) -> None:
"""Should return cached image when available."""
client, _ = db_client

cached_bytes = b"fake png data"
with (
patch("api.routers.og_images.get_cache", return_value=cached_bytes),
):
response = client.get("/og/scatter-basic/matplotlib.png")
assert response.status_code == 200
assert response.headers["content-type"] == "image/png"
assert response.content == cached_bytes

def test_get_spec_collage_no_db(self, client: TestClient) -> None:
"""Should return 503 when DB not available."""
with patch(DB_CONFIG_PATCH, return_value=False):
response = client.get("/og/scatter-basic.png")
assert response.status_code == 503

def test_get_spec_collage_spec_not_found(self, db_client) -> None:
"""Should return 404 when spec not found."""
client, _ = db_client

mock_spec_repo = MagicMock()
mock_spec_repo.get_by_id = AsyncMock(return_value=None)

with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo):
response = client.get("/og/nonexistent.png")
assert response.status_code == 404

def test_get_spec_collage_no_previews(self, db_client) -> None:
"""Should return 404 when no implementations have previews."""
client, _ = db_client

mock_impl = MagicMock()
mock_impl.library_id = "matplotlib"
mock_impl.preview_url = None # No preview

mock_spec = MagicMock()
mock_spec.id = "scatter-basic"
mock_spec.impls = [mock_impl]

mock_spec_repo = MagicMock()
mock_spec_repo.get_by_id = AsyncMock(return_value=mock_spec)

with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo):
response = client.get("/og/scatter-basic.png")
assert response.status_code == 404

def test_get_spec_collage_cached(self, db_client) -> None:
"""Should return cached collage when available."""
client, _ = db_client

cached_bytes = b"fake collage png data"
with (
patch("api.routers.og_images.get_cache", return_value=cached_bytes),
):
response = client.get("/og/scatter-basic.png")
assert response.status_code == 200
assert response.headers["content-type"] == "image/png"
assert response.content == cached_bytes

Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The test coverage for og_images router only covers error cases and cache hits, but doesn't test the successful path where images are actually fetched and processed. Consider adding tests that mock httpx.AsyncClient and the image processing functions to verify the full flow works correctly.

Copilot uses AI. Check for mistakes.
Comment thread api/routers/og_images.py Outdated
Comment on lines +107 to +111
for impl in selected_impls:
image_bytes = await _fetch_image(impl.preview_url)
images.append(image_bytes)
# Label format: "spec_id · library" like in og-image.png
labels.append(f"{spec_id} · {impl.library_id}")
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The images are fetched sequentially in a for loop (lines 107-111), which could be slow when fetching 6 images. Consider using asyncio.gather to fetch all images concurrently, which would significantly improve the response time for collage generation.

Copilot uses AI. Check for mistakes.
Comment thread core/images.py Outdated
Comment on lines +441 to +454
spec_id: str | None = None,
labels: list[str] | None = None,
) -> Image.Image | bytes:
"""Create a collage OG image from multiple plot images.

Creates a 2x3 grid (2 rows, 3 columns) with pyplots.ai branding:
- Large dominant logo and tagline at top
- 6 plots in 16:9 rounded cards arranged in 2 rows
- Labels below each card

Args:
images: List of plot images (paths, PIL Images, or bytes), up to 6
output_path: If provided, save to this path
spec_id: Optional spec ID for subtitle
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The spec_id parameter is documented as 'Optional spec ID for subtitle' but is never used in the function implementation. Either this parameter should be removed if it's not needed, or it should be used to display the spec ID somewhere in the collage (e.g., as a subtitle below the tagline).

Copilot uses AI. Check for mistakes.
Comment thread api/main.py
app.include_router(plots_router)
app.include_router(download_router)
app.include_router(seo_router)
app.include_router(og_images_router)
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

Consider adding HTTP Cache-Control headers for the /og/* endpoints in the cache middleware (around line 121 in the full file). Since the og_images endpoints return static images that are already cached internally for 1 hour, adding public cache headers would allow browsers and CDNs to cache them as well, reducing server load. For example: elif path.startswith("/og/"): response.headers["Cache-Control"] = "public, max-age=3600"

Copilot uses AI. Check for mistakes.
Comment thread core/images.py

Design matches og-image.png style:
- pyplots.ai logo at top
- Tagline "Beautiful Python plotting made easy."
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The docstring mentions 'Beautiful Python plotting made easy.' as the tagline, but the actual implementation uses 'library-agnostic, ai-powered python plotting.' (line 394). The docstring should be updated to match the actual unified tagline being used in the code.

Suggested change
- Tagline "Beautiful Python plotting made easy."
- Tagline "library-agnostic, ai-powered python plotting."

Copilot uses AI. Check for mistakes.
Comment thread docs/architecture/seo.md

Layout:
- pyplots.ai logo (centered, MonoLisa font 42px, weight 700)
- Tagline: "Beautiful Python plotting made easy."
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The documentation states the tagline is 'Beautiful Python plotting made easy.' but this doesn't match the actual tagline used in the code ('library-agnostic, ai-powered python plotting.' on line 394 of core/images.py and throughout the PR). This should be updated to reflect the correct unified tagline.

Suggested change
- Tagline: "Beautiful Python plotting made easy."
- Tagline: "library-agnostic, ai-powered python plotting."

Copilot uses AI. Check for mistakes.
Comment thread api/routers/og_images.py
Comment on lines +55 to +68
try:
# Fetch the original plot image
image_bytes = await _fetch_image(impl.preview_url)

# Create branded image
branded_bytes = create_branded_og_image(image_bytes, spec_id=spec_id, library=library)

# Cache the result
set_cache(key, branded_bytes, ttl=OG_IMAGE_CACHE_TTL)

return Response(content=branded_bytes, media_type="image/png")

except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Failed to fetch image: {e}") from e
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The image processing functions (create_branded_og_image) could raise PIL exceptions (e.g., UnidentifiedImageError, OSError) if the fetched bytes are not valid image data or if there are processing errors. Consider catching these exceptions and returning a 500 error with an appropriate message, similar to how httpx.HTTPError is caught.

Copilot uses AI. Check for mistakes.
Comment thread api/routers/og_images.py
Comment on lines +103 to +122
try:
# Fetch all images
images: list[bytes] = []
labels: list[str] = []
for impl in selected_impls:
image_bytes = await _fetch_image(impl.preview_url)
images.append(image_bytes)
# Label format: "spec_id · library" like in og-image.png
labels.append(f"{spec_id} · {impl.library_id}")

# Create collage
collage_bytes = create_og_collage(images, spec_id=spec_id, labels=labels)

# Cache the result
set_cache(key, collage_bytes, ttl=OG_IMAGE_CACHE_TTL)

return Response(content=collage_bytes, media_type="image/png")

except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Failed to fetch images: {e}") from e
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The image processing functions (create_og_collage) could raise PIL exceptions (e.g., UnidentifiedImageError, OSError) if any of the fetched bytes are not valid image data or if there are processing errors. Consider catching these exceptions and returning a 500 error with an appropriate message, similar to how httpx.HTTPError is caught.

Copilot uses AI. Check for mistakes.
- Use asyncio.gather for parallel image fetching (faster)
- Remove unused spec_id parameter from create_og_collage
- Add Cache-Control headers (public, max-age=3600) to all /og/ responses

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings January 6, 2026 10:40
- test_get_branded_impl_image_success: generates branded image from DB
- test_get_spec_collage_success: generates collage from DB with quality sorting
- test Cache-Control headers on cached responses

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 13 changed files in this pull request and generated 5 comments.

Comment thread api/routers/og_images.py
collage_bytes = create_og_collage(images, labels=labels)

# Cache the result
set_cache(key, collage_bytes, ttl=OG_IMAGE_CACHE_TTL)
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The set_cache function does not accept a ttl parameter. According to api/cache.py, set_cache only accepts (key: str, value: Any). The TTL is configured globally in the cache settings. Remove the ttl parameter from this call.

Copilot uses AI. Check for mistakes.
Comment thread core/images.py
font = ImageFont.truetype(str(monolisa_path), size)
# Set variable font weight (MonoLisa supports 100-1000)
try:
font.set_variation_by_axes([weight])
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The set_variation_by_axes method expects a list of axis values in the font's native axis order, but this code passes a single weight value. For variable fonts, you should use set_variation_by_name with a dictionary like {'wght': weight} instead, or verify the correct axis order for the font. The current implementation will likely fail or produce incorrect results with variable fonts.

Suggested change
font.set_variation_by_axes([weight])
font.set_variation_by_name({"wght": weight})

Copilot uses AI. Check for mistakes.
Comment thread docs/architecture/seo.md
Comment on lines +202 to +205
- **TTL**: 1 hour (3600 seconds)
- **Cache Key**: `og:{spec_id}:{library}` or `og:{spec_id}:collage`
- **Storage**: In-memory API cache

Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The documentation states "TTL: 1 hour (3600 seconds)" but the current cache implementation (api/cache.py) uses a global TTL configured via settings, not per-item TTL. The OG_IMAGE_CACHE_TTL constant defined in og_images.py is unused since set_cache doesn't accept a ttl parameter. Update the documentation to clarify that caching uses the global TTL from settings, and that the 1-hour Cache-Control header is set for HTTP responses.

Suggested change
- **TTL**: 1 hour (3600 seconds)
- **Cache Key**: `og:{spec_id}:{library}` or `og:{spec_id}:collage`
- **Storage**: In-memory API cache
- **Cache TTL**: Uses the global in-memory API cache TTL configured in settings (not a fixed 1-hour per-item TTL)
- **HTTP Cache-Control**: og:image responses set `Cache-Control: public, max-age=3600` (1 hour)
- **Cache Key**: `og:{spec_id}:{library}` or `og:{spec_id}:collage`
- **Storage**: In-memory API cache

Copilot uses AI. Check for mistakes.
Comment thread api/routers/og_images.py
Comment on lines +18 to +19
# Cache TTL for generated images (1 hour)
OG_IMAGE_CACHE_TTL = 3600
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The OG_IMAGE_CACHE_TTL constant is defined but never actually used since the set_cache function doesn't accept a ttl parameter. This constant should either be removed, or if per-item TTL is desired, the cache implementation needs to be updated to support it.

Copilot uses AI. Check for mistakes.
Comment thread api/routers/og_images.py
branded_bytes = create_branded_og_image(image_bytes, spec_id=spec_id, library=library)

# Cache the result
set_cache(key, branded_bytes, ttl=OG_IMAGE_CACHE_TTL)
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The set_cache function does not accept a ttl parameter. According to api/cache.py, set_cache only accepts (key: str, value: Any). The TTL is configured globally in the cache settings. Remove the ttl parameter from this call and the one on line 116.

Suggested change
set_cache(key, branded_bytes, ttl=OG_IMAGE_CACHE_TTL)
set_cache(key, branded_bytes)

Copilot uses AI. Check for mistakes.
Each E2E test now gets its own PostgreSQL schema (test_<uuid>),
eliminating race conditions when tests try to DROP/CREATE tables
concurrently. This allows tests to run in parallel without conflicts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@MarkusNeusinger MarkusNeusinger merged commit 3749b14 into main Jan 6, 2026
6 of 7 checks passed
@MarkusNeusinger MarkusNeusinger deleted the feat/og-image-branding-3172 branch January 6, 2026 10:53
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.

feat(seo): enhance og:image with branding and spec overview collage

2 participants