249 lines
8.4 KiB
Python
249 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the GitHub Social Preview card (``og-card.png``) for this repo.
|
|
|
|
Produces a 1280 by 640 PNG that GitHub displays when someone shares the
|
|
repo URL on Twitter, LinkedIn, Slack, etc. The output is committed to
|
|
the repo root; configuring GitHub's Social Preview is a separate manual
|
|
step (Settings to Social Preview to Upload).
|
|
|
|
Design intent:
|
|
|
|
- Brand-consistent palette matching rampstack.co (navy gradient base,
|
|
gold accent dot, cyan decorative pattern, white display type).
|
|
- Distinctive enough that the card does not read as the default GitHub
|
|
repo template; the dot grid on the right hints at catalog density
|
|
without being chartjunk.
|
|
- Single-line ASCII text only so it renders identically across the
|
|
fonts that ship with Pillow's bundled installations.
|
|
|
|
Counts shown on the card are pulled live from the catalog, so the card
|
|
stays in sync as the catalog grows. Re-run after meaningful changes
|
|
(skill count change, walkthrough or integration count change).
|
|
|
|
Run from the repo root::
|
|
|
|
python scripts/generate_og_card.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
SKILLS_DIR = REPO_ROOT / "skills"
|
|
OUTPUT = REPO_ROOT / "og-card.png"
|
|
|
|
# Card dimensions: GitHub Social Preview recommended size.
|
|
WIDTH, HEIGHT = 1280, 640
|
|
|
|
# Brand palette. Hex values match rampstack.co (CSS custom properties
|
|
# in app/globals.css of the rampstackco-app repo).
|
|
BG_TOP = (15, 23, 42) # slate-900-ish
|
|
BG_BOTTOM = (21, 32, 70) # brand-navy
|
|
ACCENT = (255, 210, 125) # warm gold
|
|
CYAN = (59, 180, 224) # brand-cyan
|
|
INK = (248, 250, 252) # near-white
|
|
MUTED = (203, 213, 225) # slate-300
|
|
|
|
# Typography sizes. Tuned for 1280x640 against Arial which ships with
|
|
# Pillow on Windows; falls back to Arial-equivalent on other systems.
|
|
SIZE_EYEBROW = 26
|
|
SIZE_TITLE = 100
|
|
SIZE_SUBTITLE = 36
|
|
SIZE_FOOTER = 24
|
|
|
|
|
|
def find_font(weight_hints: list[str], size: int) -> ImageFont.ImageFont:
|
|
"""Return the first available TrueType font from a hint list.
|
|
|
|
Hints are font filenames searched against the system font path. If
|
|
none load, falls back to the PIL default bitmap font, which is
|
|
coarse but never throws.
|
|
"""
|
|
for name in weight_hints:
|
|
try:
|
|
return ImageFont.truetype(name, size)
|
|
except OSError:
|
|
continue
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def gradient_background() -> Image.Image:
|
|
"""Vertical linear gradient from BG_TOP to BG_BOTTOM."""
|
|
top = np.array(BG_TOP, dtype=np.float32)
|
|
bottom = np.array(BG_BOTTOM, dtype=np.float32)
|
|
t = np.linspace(0.0, 1.0, HEIGHT, dtype=np.float32)[:, None]
|
|
rows = top * (1.0 - t) + bottom * t
|
|
arr = np.tile(rows[:, None, :], (1, WIDTH, 1)).astype(np.uint8)
|
|
return Image.fromarray(arr, mode="RGB")
|
|
|
|
|
|
def draw_dot_grid(canvas: Image.Image) -> None:
|
|
"""Subtle dot grid on the right portion of the card.
|
|
|
|
Draws a 14-column by 7-row matrix of small cyan circles, with
|
|
alpha-fading toward the left so the grid blends into the headline
|
|
region rather than competing with it.
|
|
"""
|
|
overlay = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(overlay)
|
|
|
|
cols, rows = 14, 7
|
|
grid_x_start = 760 # leaves room for headline on the left
|
|
grid_x_end = 1200
|
|
grid_y_start = 80
|
|
grid_y_end = 560
|
|
radius = 5
|
|
|
|
col_step = (grid_x_end - grid_x_start) / (cols - 1)
|
|
row_step = (grid_y_end - grid_y_start) / (rows - 1)
|
|
|
|
for c in range(cols):
|
|
x = grid_x_start + int(round(c * col_step))
|
|
# Fade from 30 alpha on the left edge of the grid up to 90 on the right.
|
|
edge_t = c / (cols - 1)
|
|
alpha = int(round(30 + edge_t * 60))
|
|
for r in range(rows):
|
|
y = grid_y_start + int(round(r * row_step))
|
|
color = (CYAN[0], CYAN[1], CYAN[2], alpha)
|
|
draw.ellipse(
|
|
(x - radius, y - radius, x + radius, y + radius),
|
|
fill=color,
|
|
)
|
|
|
|
canvas.alpha_composite(overlay)
|
|
|
|
|
|
def count_skills() -> int:
|
|
"""Walk skills/ and count folders that contain a SKILL.md.
|
|
|
|
Avoids importing the README generator to keep this script
|
|
self-contained; the heuristic matches the linter's catalog count.
|
|
"""
|
|
if not SKILLS_DIR.exists():
|
|
return 0
|
|
return sum(
|
|
1
|
|
for child in SKILLS_DIR.iterdir()
|
|
if child.is_dir() and (child / "SKILL.md").exists()
|
|
)
|
|
|
|
|
|
def count_walkthroughs() -> int:
|
|
"""Read the rampstackco-app sibling repo for a live walkthrough count.
|
|
|
|
Falls back to 0 if the sibling repo is not checked out next to this
|
|
one, which keeps the script runnable in CI clones that only fetch
|
|
claude-skills.
|
|
"""
|
|
sibling = REPO_ROOT.parent.parent / "rampstackco-app" / "app" / "walkthroughs" / "_data" / "walkthroughs.ts"
|
|
if not sibling.exists():
|
|
return 0
|
|
text = sibling.read_text(encoding="utf-8")
|
|
# Each walkthrough entry begins with ` slug: "..."`. Match those
|
|
# specifically rather than the generic `slug: ...` pattern that also
|
|
# appears in type definitions.
|
|
return len(re.findall(r"^ slug: \"[^\"]+\"", text, re.MULTILINE))
|
|
|
|
|
|
def count_audience_tracks() -> int:
|
|
"""Number of audience suite tiles on the rampstack.co homepage.
|
|
|
|
Sourced from app/page.tsx in the sibling repo. Falls back to 6,
|
|
which matches the count at the time this script was written.
|
|
"""
|
|
sibling = REPO_ROOT.parent.parent / "rampstackco-app" / "app" / "page.tsx"
|
|
if not sibling.exists():
|
|
return 6
|
|
text = sibling.read_text(encoding="utf-8")
|
|
match = re.search(r"const SKILL_SUITES: SuiteCard\[\]\s*=\s*\[(.*?)\n\];", text, re.DOTALL)
|
|
if not match:
|
|
return 6
|
|
return len(re.findall(r"^ \{$", match.group(1), re.MULTILINE))
|
|
|
|
|
|
def main() -> int:
|
|
canvas = gradient_background().convert("RGBA")
|
|
draw_dot_grid(canvas)
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
# Type setting.
|
|
font_eyebrow = find_font(["arialbd.ttf", "DejaVuSans-Bold.ttf"], SIZE_EYEBROW)
|
|
font_title = find_font(["arialbd.ttf", "DejaVuSans-Bold.ttf"], SIZE_TITLE)
|
|
font_subtitle = find_font(["arial.ttf", "DejaVuSans.ttf"], SIZE_SUBTITLE)
|
|
font_footer = find_font(["arial.ttf", "DejaVuSans.ttf"], SIZE_FOOTER)
|
|
|
|
skill_count = count_skills()
|
|
walkthrough_count = count_walkthroughs()
|
|
track_count = count_audience_tracks()
|
|
|
|
eyebrow = "OPEN SOURCE - MIT LICENSED"
|
|
title_line_1 = "Brand Build Skills"
|
|
title_line_2 = "for Claude."
|
|
subtitle = (
|
|
f"{skill_count} skills - {walkthrough_count} walkthroughs - "
|
|
f"{track_count} audience tracks"
|
|
)
|
|
footer_left = "github.com/rampstackco/claude-skills"
|
|
footer_right = "rampstack.co"
|
|
|
|
# Top-left accent dot + eyebrow.
|
|
pad_x = 80
|
|
eyebrow_y = 80
|
|
dot_radius = 7
|
|
draw.ellipse(
|
|
(pad_x, eyebrow_y + 14, pad_x + dot_radius * 2, eyebrow_y + 14 + dot_radius * 2),
|
|
fill=ACCENT,
|
|
)
|
|
# Tighten letter-tracking by drawing the eyebrow as one block; the
|
|
# text method already kerns reasonably for caps + spacing.
|
|
draw.text(
|
|
(pad_x + dot_radius * 2 + 16, eyebrow_y),
|
|
eyebrow,
|
|
fill=MUTED,
|
|
font=font_eyebrow,
|
|
)
|
|
|
|
# Headline. Two lines stacked so the display size stays comfortable
|
|
# inside the 1280-wide canvas without crowding the dot grid.
|
|
title_y = 180
|
|
line_height = SIZE_TITLE + 12
|
|
draw.text((pad_x, title_y), title_line_1, fill=INK, font=font_title)
|
|
draw.text((pad_x, title_y + line_height), title_line_2, fill=INK, font=font_title)
|
|
|
|
# Subtitle stat strip.
|
|
subtitle_y = title_y + line_height * 2 + 30
|
|
draw.text((pad_x, subtitle_y), subtitle, fill=MUTED, font=font_subtitle)
|
|
|
|
# Footer row.
|
|
footer_y = HEIGHT - 60
|
|
draw.text((pad_x, footer_y), footer_left, fill=INK, font=font_footer)
|
|
# Right-align the rampstack.co mark.
|
|
bbox = draw.textbbox((0, 0), footer_right, font=font_footer)
|
|
text_width = bbox[2] - bbox[0]
|
|
draw.text(
|
|
(WIDTH - pad_x - text_width, footer_y),
|
|
footer_right,
|
|
fill=INK,
|
|
font=font_footer,
|
|
)
|
|
|
|
# Output.
|
|
canvas.convert("RGB").save(OUTPUT, "PNG", optimize=True)
|
|
print(
|
|
f"Wrote {OUTPUT.relative_to(REPO_ROOT)} "
|
|
f"({skill_count} skills, {walkthrough_count} walkthroughs, "
|
|
f"{track_count} audience tracks)"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|