Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e034265
feat: add social image generation functionality with templates and co…
kacperlukawski Mar 3, 2026
542c3bb
fix: resolve single file path in collect_files function
kacperlukawski Mar 3, 2026
7e7f52d
feat: update social image generation configuration and templates for …
kacperlukawski Mar 4, 2026
94c7a22
feat: enhance social image generation with dynamic text positioning a…
kacperlukawski Mar 4, 2026
37f84f6
feat: refactor social image placement test to utilize ImageCompositor…
kacperlukawski Mar 4, 2026
3e1daf8
fix: adjust font size and gap in social image configuration for bette…
kacperlukawski Mar 4, 2026
6a8fac4
feat: add example social images for blog and Google GenAI integrations
kacperlukawski Mar 4, 2026
2164aaa
feat: update social image integration template and fonts
kacperlukawski Mar 12, 2026
e7ab881
fix: adjust gap in social image configuration and update ImageMagick …
kacperlukawski Mar 12, 2026
efcc8f3
feat: update social image configuration with new fonts and layout adj…
kacperlukawski Mar 12, 2026
ae66d80
feat: update social image templates and configuration for cookbook an…
kacperlukawski Mar 12, 2026
7625fe9
fix: improve output message format in FileProcessor for ImageMagick r…
kacperlukawski Mar 12, 2026
7649301
fix: improve error and status messages in FileProcessor with relative…
kacperlukawski Mar 12, 2026
c2a4e14
feat: add --force option to regenerate images if they already exist
kacperlukawski Mar 12, 2026
1896926
Add new social images and release notes for various versions
kacperlukawski Mar 12, 2026
edd6614
Revert "Add new social images and release notes for various versions"
kacperlukawski Mar 12, 2026
1618a0d
refactor: remove output_dir from template configuration and set defau…
kacperlukawski Mar 12, 2026
e4fbae7
Add example images
kacperlukawski Mar 12, 2026
5dc3425
fix: update social image path to use section instead of type
kacperlukawski Mar 16, 2026
0aa8039
fix: ensure consistent text height measurement
kacperlukawski Mar 16, 2026
4a5a776
chore: new images for integrations, release notes, and tutorials
kacperlukawski Mar 16, 2026
cb842ac
refactor(social-images): update config for reviewer feedback
kacperlukawski Jun 22, 2026
8ebff00
feat(social-images): strip emoji from titles and support exclude list
kacperlukawski Jun 22, 2026
d2b4699
chore(social-images): remove unused font files
kacperlukawski Jun 22, 2026
e33f7d6
chore(social-images): remove blog and author generated social images
kacperlukawski Jun 22, 2026
d03c809
docs(social-images): update README to reflect current config
kacperlukawski Jun 22, 2026
232361c
fix(social-images): fix test-placement.py compatibility issues
kacperlukawski Jun 22, 2026
09547f8
chore(social-images): exclude ambassador-perks from image generation
kacperlukawski Jun 22, 2026
a16b01f
chore: regenerate images
kacperlukawski Jun 22, 2026
7408cc6
feat(social-images): add GitHub Action to auto-generate images on PR
kacperlukawski Jun 23, 2026
4d03a64
chore(social-images): remove image generation from build.sh
kacperlukawski Jun 23, 2026
3ae8d52
docs(social-images): update README to reflect CI-based generation
kacperlukawski Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/social-images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Social Images

on:
pull_request:
paths:
- 'content/**/*.md'

jobs:
generate:
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
token: ${{ secrets.GITHUB_TOKEN }}

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'

- name: Install ImageMagick
run: |
sudo apt-get update -qq
sudo apt-get install -y imagemagick
# Ubuntu ships ImageMagick 6 (binary: convert). generate.py expects `magick`
# (IM7 name). Create an alias so the script works without modification.
sudo ln -sf /usr/bin/convert /usr/local/bin/magick
magick -version

- name: Install Python dependencies
run: pip install -r scripts/social-images/requirements.txt

- name: Find changed Markdown files
id: diff
run: |
git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}

git diff --name-only --diff-filter=AM FETCH_HEAD HEAD \
| grep '^content/.*\.md$' > /tmp/added_modified.txt || true

git diff --name-only --diff-filter=DR FETCH_HEAD HEAD \
| grep '^content/.*\.md$' > /tmp/deleted.txt || true

echo "has_added=$([ -s /tmp/added_modified.txt ] && echo 'true' || echo 'false')" >> "$GITHUB_OUTPUT"
echo "has_deleted=$([ -s /tmp/deleted.txt ] && echo 'true' || echo 'false')" >> "$GITHUB_OUTPUT"

- name: Generate social images for added/modified files
if: steps.diff.outputs.has_added == 'true'
run: |
while IFS= read -r f; do
echo "Generating: $f"
python3 scripts/social-images/generate.py --file "$f" --force
done < /tmp/added_modified.txt

- name: Remove orphaned images for deleted files
if: steps.diff.outputs.has_deleted == 'true'
run: |
while IFS= read -r f; do
# Mirror ContentFile._detect_type (generate.py:170): first segment after content/
type="${f#content/}"
type="${type%%/*}"
# Mirror ContentFile._derive_slug (generate.py:178): parent dir for index files, else stem
filename="$(basename "$f")"
if [[ "$filename" == "index.md" || "$filename" == "_index.md" ]]; then
slug="$(basename "$(dirname "$f")")"
else
slug="${filename%.md}"
fi
img="static/images/social/${type}/${slug}.png"
echo "Removing orphan: $img"
git rm -f --ignore-unmatch "$img"
done < /tmp/deleted.txt

- name: Commit and push images
if: github.event.pull_request.head.repo.full_name == github.repository
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add static/images/social/
if git diff --cached --quiet; then
echo "No image changes to commit."
else
git commit -m "chore(social-images): sync images for changed content"
git push
fi

- name: Notice for fork PRs
if: github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::notice::Fork PR — social images not committed automatically. Run 'python3 scripts/social-images/generate.py' locally (needs ImageMagick + Python deps) or ask a maintainer to regenerate."
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,4 @@ haystack-advent
content/advent-of-haystack/*
!content/advent-of-haystack/_index.md

static/downloads
static/downloads
2 changes: 1 addition & 1 deletion assets/jsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"baseUrl": ".",
"paths": {
"*": [
"..\\themes\\haystack\\assets\\*"
"../themes/haystack/assets/*"
]
}
}
Expand Down
121 changes: 121 additions & 0 deletions scripts/social-images/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Social image generation

Generates Open Graph / Twitter Card preview images for Hugo content by compositing text onto PNG templates using ImageMagick.

Images are placed at `static/images/social/{type}/{slug}.png`. Hugo's `opengraph.html` partial automatically detects a generated image at that path and uses it — no front-matter changes needed in any content file.

## Prerequisites

```bash
brew install imagemagick
pip3 install -r scripts/social-images/requirements.txt
```

## CI integration

`.github/workflows/social-images.yml` runs on every PR that changes a `content/**/*.md` file. It generates or removes images for the changed files and commits them back to the PR branch via `github-actions[bot]`. No local tooling required.

Fork PRs emit a workflow notice instead — run `generate.py` locally or ask a maintainer.

## Local usage

```bash
# Preview everything without writing files
python3 scripts/social-images/generate.py --dry-run

# Generate for all content types
python3 scripts/social-images/generate.py

# One content type only
python3 scripts/social-images/generate.py --type release-notes

# Single file
python3 scripts/social-images/generate.py --file content/release-notes/2.25.0.md
```

## Adding templates

Place one PNG per content type in `scripts/social-images/templates/`:

| File | Used for |
|---|---|
| `blog.png` | `content/blog/` posts |
| `release-notes.png` | `content/release-notes/` pages |
| `tutorials.png` | `content/tutorials/` pages |
| `cookbook.png` | `content/cookbook/` pages |
| `Integration.png` | `content/integrations/` pages |
| `fallback.png` | Everything else |

Recommended size: **1200 × 630 px** (standard OG image ratio).

## Configuration

`config.yaml` controls text placement for each template. Each content type can define multiple named fields (`title`, `description`, etc.) that map to front-matter values.

Use `exclude:` to skip content types entirely (e.g. blog posts that already have their own thumbnail):

```yaml
exclude:
- blog
- authors

templates:
release-notes:
template: scripts/social-images/templates/release-notes.png
fields:
title:
font: scripts/social-images/fonts/HafferBold.ttf
size: 48 # font size in points
color: "#2558ff"
gravity: NorthWest # ImageMagick anchor: NorthWest, Center, South, etc.
left: 66 # pixel offset from the gravity anchor (horizontal)
top: 232 # pixel offset from the gravity anchor (vertical)
max_width: 830 # text wraps within this box width
max_height: 165 # text is clipped beyond this height
description:
font: scripts/social-images/fonts/Inter-Regular.ttf
size: 20
color: "#2558ff"
gravity: NorthWest
left: 66
anchor: title # position below the rendered bottom of the title field
gap: 15 # extra pixels of padding after the title
max_width: 830 # text wraps within this box width
max_height: 165 # text is clipped beyond this height
```

**Field reference:**

| Key | Required | Description |
|---|---|---|
| `font` | yes | Path to a TTF file (relative to repo root) |
| `size` | yes | Font size in points (matches Canva pt values at 96 DPI) |
| `color` | yes | Hex color string |
| `gravity` | yes | ImageMagick reference corner: `NorthWest`, `Center`, `South`, etc. |
| `left` | yes* | Horizontal pixel offset from the gravity anchor (*defaults to anchor's `left` when `anchor` is set) |
| `top` | yes* | Vertical pixel offset from the gravity anchor (*not needed when `anchor` is set) |
| `max_width` | yes | Caption box width in pixels (controls line wrapping) |
| `max_height` | yes | Caption box height in pixels (text is clipped if it overflows) |
| `anchor` | no | Name of another field; positions this field's top below that field's rendered bottom edge |
| `gap` | no | Extra pixels of padding below the anchor field (default: 0) |

**Dynamic positioning with `anchor`:** when set, the canvas position is computed so that exactly `gap` pixels of visual space appear between the anchor's last text pixel and this field's first text pixel:

```
canvas_top = anchor_field.top + anchor_bottom_offset + gap - this_field_top_offset
```

`gap: 0` means the two text blocks touch with no overlap and no extra space. Both offsets are measured from each field's own caption canvas using ImageMagick's trim geometry.

Available fonts (in `scripts/social-images/fonts/`):

- `HafferBold.ttf`, `HafferMedium.ttf`, `HafferRegular.ttf` — Haffer (headings + body)
- `Inter-Regular.ttf` — Inter (body text)

## How Hugo picks up the images

`themes/haystack/layouts/partials/opengraph.html` derives the expected image path from the page's content type and slug, then checks for the file with `os.FileExists`. Priority order:

1. Explicit `images` array in front matter (manual override)
2. Generated social image at `static/images/social/{type}/{slug}.png`
3. Site-wide default from `config.toml`
146 changes: 146 additions & 0 deletions scripts/social-images/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Social image generation configuration.
#
# Each key under `templates` maps to a content type inferred from the file path
# (e.g. content/blog/ → "blog"). If a content type has no entry here, the
# "fallback" template is used.
#
# Field keys are arbitrary — they must match a front-matter key that the script
# knows how to read (title, description). The script skips a field silently if
# the front-matter value is empty.
#
# Field options:
# font - path to a TTF file (relative to the repo root)
# size - font size in points (matches Canva pt values; rendered at 96 DPI)
# color - hex color string
# gravity - ImageMagick gravity: NorthWest, North, NorthEast, West, Center,
# East, SouthWest, South, SouthEast
# left - pixel offset from the gravity anchor (horizontal; required unless anchor is
# set, in which case it defaults to the anchor field's left value)
# top - pixel offset from the gravity anchor (vertical; required unless anchor is set)
# max_width - caption box width in pixels (controls line wrapping)
# max_height - caption box height in pixels (text is clipped if it overflows)
# anchor - (optional) name of another field whose rendered bottom edge
# determines this field's top; replaces top
# gap - (optional) extra pixels of padding below the anchor (default 0)

# Content types listed here are skipped entirely (no image generated).
exclude:
- blog
- authors
- ambassador-perks

templates:
cookbook:
template: scripts/social-images/templates/cookbook.png
fields:
title:
font: scripts/social-images/fonts/HafferBold.ttf
size: 48
color: "#2558ff"
gravity: NorthWest
left: 66
top: 232
max_width: 830
max_height: 165
description:
font: scripts/social-images/fonts/Inter-Regular.ttf
size: 20
color: "#2558ff"
gravity: NorthWest
left: 66
anchor: title
gap: 15
max_width: 830
max_height: 165

integrations:
template: scripts/social-images/templates/Integration.png
fields:
title:
font: scripts/social-images/fonts/HafferBold.ttf
size: 48
color: "#2558ff"
gravity: NorthWest
left: 66
top: 232
max_width: 830
max_height: 165
description:
font: scripts/social-images/fonts/Inter-Regular.ttf
size: 20
color: "#2558ff"
gravity: NorthWest
left: 66
anchor: title
gap: 15
max_width: 830
max_height: 165

release-notes:
template: scripts/social-images/templates/release-notes.png
fields:
title:
font: scripts/social-images/fonts/HafferBold.ttf
size: 48
color: "#2558ff"
gravity: NorthWest
left: 66
top: 232
max_width: 830
max_height: 165
description:
font: scripts/social-images/fonts/Inter-Regular.ttf
size: 20
color: "#2558ff"
gravity: NorthWest
left: 66
anchor: title
gap: 15
max_width: 830
max_height: 165

tutorials:
template: scripts/social-images/templates/tutorials.png
fields:
title:
font: scripts/social-images/fonts/HafferBold.ttf
size: 48
color: "#2558ff"
gravity: NorthWest
left: 66
top: 232
max_width: 830
max_height: 165
description:
font: scripts/social-images/fonts/Inter-Regular.ttf
size: 20
color: "#2558ff"
gravity: NorthWest
left: 66
anchor: title
gap: 15
max_width: 830
max_height: 165

fallback:
template: scripts/social-images/templates/fallback.png
fields:
title:
font: scripts/social-images/fonts/HafferBold.ttf
size: 48
color: "#2558ff"
gravity: NorthWest
left: 66
top: 232
max_width: 830
max_height: 165
description:
font: scripts/social-images/fonts/Inter-Regular.ttf
size: 20
color: "#2558ff"
gravity: NorthWest
left: 66
anchor: title
gap: 15
max_width: 830
max_height: 165
Binary file added scripts/social-images/fonts/HafferBold.ttf
Binary file not shown.
Binary file added scripts/social-images/fonts/HafferMedium.ttf
Binary file not shown.
Binary file added scripts/social-images/fonts/HafferRegular.ttf
Binary file not shown.
Binary file added scripts/social-images/fonts/Inter-Regular.ttf
Binary file not shown.
Loading
Loading