diff --git a/.claude/commands/workshop-banner/SKILL.md b/.claude/commands/workshop-banner/SKILL.md new file mode 100644 index 000000000000..d6b4b4d83fad --- /dev/null +++ b/.claude/commands/workshop-banner/SKILL.md @@ -0,0 +1,200 @@ +--- +name: workshop-banner +description: "Generate a workshop/event banner image. Auto-detects the event from arguments or git status, reads frontmatter for title, event type, and presenters, then renders a 1368x768 PNG saved into the event directory with frontmatter updated. Use when the user types /workshop-banner or asks to create, generate, or regenerate a workshop banner, event banner, or social image for an event. Accepts optional arguments like an event slug, file path, or overrides." +--- + +# `/workshop-banner` — Generate Workshop/Event Banner + +You are generating a banner image for a Pulumi workshop or event. Follow these steps precisely. + +**Skill directory**: `.claude/commands/workshop-banner/` — all paths below are relative to the project root unless noted. + +## [Step 1/4] Find the workshop/event + +Locate the event that needs a banner: + +1. If `$ARGUMENTS` contains a file path (e.g., `content/events/foo/index.md`), use that directly. +2. If `$ARGUMENTS` contains a bare slug (e.g., `designing-reusable-infrastructure-as-code`), resolve it to `content/events//index.md`. +3. Otherwise, run `git status` and `git diff --name-only` to find modified/untracked `content/events/*/index.md` files. +4. If multiple events are found, use `AskUserQuestion` to ask which one. +5. If no event is found, ask the user to specify a path or slug. + +Read the full event file (frontmatter + body). + +## [Step 2/4] Parse event frontmatter & user requests + +### From the event frontmatter, extract: + +| Config key | Frontmatter source | Parsing notes | +|---|---|---| +| `title` | `main.title` (fall back to top-level `title`) | Use as-is | +| `event_type` | `main.event_type` | Usually `"workshop"` or `"event"` | +| `speaker_name` | `main.presenters[].name` | See presenter selection in Step 3 | +| `speaker_title` | `main.presenters[].role` — portion **before** the last comma | e.g., `"Senior Solutions Architect, Pulumi"` → `"Senior Solutions Architect"` | +| `speaker_company` | `main.presenters[].role` — portion **after** the last comma | → `"Pulumi"`. If no comma in role, treat entire string as `speaker_title`, leave `speaker_company` empty | +| `speaker_photo` | `main.presenters[].photo` | Convert site-root path to absolute filesystem path: prepend `/static`. E.g., `/images/team/engin-diri.jpg` → `/static/images/team/engin-diri.jpg` | + +### From `$ARGUMENTS`, parse user preferences: + +- **CTA text**: e.g., `"Sign Up"`, `"Join Now"` +- **Title override**: if the user explicitly provides different text in quotes, use that instead of the frontmatter title +- **Presenter name**: if a specific presenter name is mentioned, pre-select that presenter + +If `$ARGUMENTS` fully specifies everything needed (event path + any overrides) and the event has exactly one presenter, skip the interactive questions and go straight to Step 3's rendering section. + +## [Step 3/4] Progressive questions & render + +### Interactive selection (up to 4 progressive questions) + +Ask the following questions **progressively** (one at a time) using `AskUserQuestion`. Skip any question that was already answered via `$ARGUMENTS` or is not applicable. + +--- + +### Question 1: Featured speaker (only if multiple presenters) + +If `main.presenters` contains more than one presenter, ask which one to feature. If there is exactly one presenter (or zero), skip this question and use that presenter automatically. + +``` +header: "Featured Speaker" +question: "Which presenter should be featured on the banner?" +options: + - label: "" + description: "" + - label: "" + description: "" + ... +``` + +Build options dynamically from `main.presenters[]`. + +--- + +### Question 2: CTA button text + +Skip if already provided via `$ARGUMENTS`. + +``` +header: "CTA Button" +question: "What text for the call-to-action button?" +options: + - label: "Register" + description: "Default CTA for workshops" + - label: "Sign Up" + description: "Alternative registration CTA" + - label: "Join Now" + description: "For live events" + - label: "Watch Now" + description: "For replays (event has a youtube_url)" + - label: "Custom" + description: "Enter your own CTA text" +``` + +If the event has a `youtube_url` set, suggest "Watch Now" as the default. Otherwise default is "Register". If the user selects "Custom", follow up by asking for their text. + +--- + +### Company logo (hard-coded) + +Always use the Pulumi logo at `static/logos/brand/logo-on-black.png`. Convert to absolute path for the config: `/static/logos/brand/logo-on-black.png`. Do **not** ask the user about this — it is always included. + +--- + +### Question 3: Partner logos (optional) + +Only ask this if `main.tags.clouds` or `main.tags.topics` suggest a partner context (e.g., clouds contains `"AWS"`, `"Azure"`, `"Google Cloud"`). + +``` +header: "Partner Logos" +question: "Add partner/technology logos to the bottom-right?" +options: + - label: "Skip" + description: "No partner logos" + - label: "Add logos" + description: "Provide paths to partner logo files" +``` + +If the user selects "Add logos", ask for: +1. Paths to logo files (absolute paths) +2. Optional `partner_text` (e.g., `"Powered by"`) +3. Whether logos go before or after the text + +Suggest relevant logos based on tags — e.g., if `clouds: ["AWS"]`, mention the user could provide an AWS logo. + +**Logo variant rule**: Partner logos render on a light (`#F9F9F9`) background. Always use the dark/color version of a logo — avoid files with `white`, `_white`, or `-white` in the name (e.g., use `aws.svg` not `logo-aws_white.png`). When searching `static/logos/tech/`, check the filename and prefer the variant without a white/light suffix. + +--- + +### Build config and render + +**Build the JSON config** with all collected values: + +```json +{ + "event_type": "", + "title": "", + "cta_text": "", + "speaker_photo": "", + "speaker_name": "", + "speaker_title": "", + "speaker_company": "", + "company_logo": "", + "partner_logos": [""], + "partner_text": "", + "partner_logos_after_text": [""] +} +``` + +Write the config to `/tmp/banner_config_.json`. + +**Determine output path**: `content/events//meta.png` — saves the banner alongside the event's `index.md`. + +**Render** using the HTML renderer (requires Playwright): + +```bash +python3 /scripts/render_banner_html.py \ + --config /tmp/banner_config_.json \ + --output content/events//meta.png +``` + +Read the output PNG to show the result to the user. + +## [Step 4/4] Confirm & update frontmatter + +1. Verify the PNG was created successfully at the expected path. +2. Check the event's frontmatter for `meta_image:` and `meta_image_square:` — add or update both: + ```yaml + meta_image: /events//meta.png + meta_image_square: /events//meta-square.png + ``` + These are Hugo site-root-relative paths (no `content/` prefix). +3. Report to the user: + - Which event was used (path and title) + - Which presenter was featured + - What config was applied (event type, CTA, logos) + - Where `meta.png` and `meta-square.png` were saved + - That `meta_image` and `meta_image_square` were updated in frontmatter + - Remind them to preview the images to make sure they look good +4. Ask if any adjustments are needed (text, layout, speaker choice, etc.). + +## Layout reference + +- **Left panel** (~65% width, `#F9F9F9` background): event type badge (top-left), title (vertically centered), partner logos + label (above CTA), CTA button (bottom-left) +- **Right dark panel** (~35% width, `#20054E`): company logo (top-center), circular speaker photo with decorative rings (center), speaker name / title / company (below photo) +- **Background decoration**: subtle gray curved lines (`#DFDFDF`) in the top-right and bottom-right corners of the left panel + +## Notes + +- The HTML renderer uses Google Fonts (Inter) for all text. +- Title auto-sizes based on character count — two separate scales for landscape and square. +- Speaker photo is displayed in a circle with a decorative ring; a placeholder silhouette shows when no photo is provided. +- Images are embedded as base64 data URIs — all paths must be absolute filesystem paths. +- Partner logos render on the light `#F9F9F9` background; always use dark/color variants. +- Requires `playwright` Python package (`pip install playwright && playwright install chromium`). +- Two PNGs are produced per run: `meta.png` (1200×628 landscape) and `meta-square.png` (628×628 square). + +## Error handling + +- If `render_banner_html.py` fails, read the error output and try to fix the config (common issues: invalid photo path, missing file). +- If a presenter has no `photo` field, the renderer will show a placeholder silhouette — this is fine. +- If the event has no presenters at all, skip the speaker fields entirely (the banner will show a placeholder). +- If the frontmatter title is very long (>65 characters), mention to the user that they can provide a shorter override for better visual results — at that length the font drops to its smallest size. diff --git a/.claude/commands/workshop-banner/scripts/render_banner_html.py b/.claude/commands/workshop-banner/scripts/render_banner_html.py new file mode 100644 index 000000000000..e5e089726eb5 --- /dev/null +++ b/.claude/commands/workshop-banner/scripts/render_banner_html.py @@ -0,0 +1,705 @@ +#!/usr/bin/env python3 +""" +Render workshop banners using HTML/CSS + Playwright screenshot. +Produces two PNGs per run: + - — landscape 1200x628 + - -square.png — square 628x628 + +Usage: + python3 render_banner_html.py --config config.json --output banner.png +""" + +import argparse +import base64 +import html +import json +import re +from pathlib import Path + +HTML_TEMPLATE = """ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ +
{speaker_photo_html}
+
{speaker_name}
+ {speaker_role_html} + {speaker_company_html} +
+ +
+
{badge_text}
+

{title}

+ {partner_html} +
{cta_text}
+
+ +""" + + +def image_to_data_uri(path): + """Convert an image file to a base64 data URI.""" + p = Path(path) + if not p.exists(): + return None + suffix = p.suffix.lower() + mime = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", + ".gif": "image/gif", + ".webp": "image/webp", + }.get(suffix, "image/png") + data = base64.b64encode(p.read_bytes()).decode() + return f"data:{mime};base64,{data}" + + +def build_html(config): + """Build the HTML string from config.""" + badge_text = html.escape(config.get("event_type", "workshop").capitalize()) + title_len = len(config.get("title", "")) + title = html.escape(config.get("title", "")) + cta_text = html.escape(config.get("cta_text", "Register")) + speaker_name = html.escape(config.get("speaker_name", "")) + speaker_title = html.escape(config.get("speaker_title", "")) + speaker_company = html.escape(config.get("speaker_company", "")) + + # Title size: scale down for longer titles (calibrated for 1200x628 at max-width 692px) + if title_len <= 15: + title_size = 80 + elif title_len <= 28: + title_size = 72 + elif title_len <= 43: + title_size = 64 + elif title_len <= 60: + title_size = 52 + else: + title_size = 42 + + # Speaker role and company as separate right-panel elements + speaker_role_html = ( + f'
{speaker_title}
' if speaker_title else "" + ) + speaker_company_html = ( + f'
{speaker_company}
' + if speaker_company + else "" + ) + + # Speaker photo + speaker_photo_path = config.get("speaker_photo", "") + if speaker_photo_path: + uri = image_to_data_uri(speaker_photo_path) + if uri: + speaker_photo_html = ( + f'Speaker' + ) + else: + speaker_photo_html = _placeholder_svg() + else: + speaker_photo_html = _placeholder_svg() + + # Company logo — img tag only (template wraps it in .company-logo) + company_logo_path = config.get("company_logo", "") + company_logo_html = "" + if company_logo_path: + uri = image_to_data_uri(company_logo_path) + if uri: + company_logo_html = f'Logo' + + # Partner logos — combine both arrays for backward compatibility + partner_logos = list(config.get("partner_logos", [])) + partner_logos += list(config.get("partner_logos_after_text", [])) + partner_text = config.get("partner_text", "") + if partner_text: + partner_text = html.escape(partner_text.strip()) + + partner_html = "" + if partner_logos or partner_text: + label_html = ( + f'{partner_text}' if partner_text else "" + ) + label_row = ( + f'
{label_html}' + f'
' + ) + logo_imgs = [] + for lp in partner_logos: + uri = image_to_data_uri(lp) + if uri: + logo_imgs.append(f'Partner') + logos_row = ( + f'
{"".join(logo_imgs)}
' + if logo_imgs + else "" + ) + partner_html = f'
{label_row}{logos_row}
' + + return HTML_TEMPLATE.format( + title_size=title_size, + badge_text=badge_text, + title=title, + cta_text=cta_text, + speaker_name=speaker_name, + speaker_role_html=speaker_role_html, + speaker_company_html=speaker_company_html, + speaker_photo_html=speaker_photo_html, + company_logo_html=company_logo_html, + partner_html=partner_html, + ) + + +HTML_TEMPLATE_SQUARE = """ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ {company_logo_html} +
+
{badge_text}
+

{title}

+ {partner_html} +
{cta_text}
+
+ +""" + + +def build_html_square(config): + """Build the HTML string for the 628x628 square variant.""" + badge_text = html.escape(config.get("event_type", "workshop").capitalize()) + title_len = len(config.get("title", "")) + title = html.escape(config.get("title", "")) + cta_text = html.escape(config.get("cta_text", "Register")) + + # Title size calibrated for 628x628, max-width 537px + title_len = len(title) + if title_len <= 15: + title_size = 68 + elif title_len <= 28: + title_size = 60 + elif title_len <= 43: + title_size = 52 + elif title_len <= 65: + title_size = 42 + else: + title_size = 34 + + # Company logo badge (absolute top-right block) + company_logo_path = config.get("company_logo", "") + company_logo_html = "" + if company_logo_path: + uri = image_to_data_uri(company_logo_path) + if uri: + company_logo_html = ( + f'
Logo
' + ) + + # Partner section + partner_logos = list(config.get("partner_logos", [])) + partner_logos += list(config.get("partner_logos_after_text", [])) + partner_text = config.get("partner_text", "") + if partner_text: + partner_text = html.escape(partner_text.strip()) + + partner_html = "" + if partner_logos or partner_text: + label_html = ( + f'{partner_text}' + if partner_text + else "" + ) + label_row = ( + f'
{label_html}' + f'
' + ) + logo_imgs = [] + for lp in partner_logos: + uri = image_to_data_uri(lp) + if uri: + logo_imgs.append(f'Partner') + logos_row = ( + f'
{"" .join(logo_imgs)}
' + if logo_imgs + else "" + ) + partner_html = ( + f'
{label_row}{logos_row}
' + ) + + return HTML_TEMPLATE_SQUARE.format( + title_size=title_size, + badge_text=badge_text, + title=title, + cta_text=cta_text, + company_logo_html=company_logo_html, + partner_html=partner_html, + ) + + +def _placeholder_svg(): + return """
+ +
""" + + +def render(config, output_path): + """Render landscape and square banners to PNG using Playwright.""" + html_landscape = build_html(config) + html_square = build_html_square(config) + + square_path = str( + Path(output_path).with_name( + Path(output_path).stem + "-square" + Path(output_path).suffix + ) + ) + + tmp_landscape = Path("/tmp/banner_render.html") + tmp_square = Path("/tmp/banner_render_square.html") + tmp_landscape.write_text(html_landscape) + tmp_square.write_text(html_square) + + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + browser = p.chromium.launch() + + page = browser.new_page(viewport={"width": 1200, "height": 628}) + page.goto(f"file://{tmp_landscape}") + page.wait_for_load_state("networkidle") + page.screenshot(path=output_path, type="png") + + page = browser.new_page(viewport={"width": 628, "height": 628}) + page.goto(f"file://{tmp_square}") + page.wait_for_load_state("networkidle") + page.screenshot(path=square_path, type="png") + + browser.close() + + print(f"Banner saved to: {output_path}") + print(f"Square banner saved to: {square_path}") + + +def to_kebab(text): + """Convert text to kebab-case.""" + text = text.lower().strip() + text = re.sub(r"[^a-z0-9\s-]", "", text) + text = re.sub(r"[\s_]+", "-", text) + text = re.sub(r"-+", "-", text) + return text.strip("-") + + +def auto_filename(config): + """Generate kebab-case filename: -<speaker-name>.png""" + title = config.get("title", "banner") + speaker_name = config.get("speaker_name", "") + parts = [to_kebab(title)] + if speaker_name: + parts.append(to_kebab(speaker_name)) + return "-".join(parts) + ".png" + + +def main(): + parser = argparse.ArgumentParser( + description="Render workshop banner (HTML)") + parser.add_argument("--config", required=True, help="JSON config path") + parser.add_argument( + "--output", default=None, help="Output PNG path (auto-generated if omitted)" + ) + args = parser.parse_args() + + with open(args.config) as f: + config = json.load(f) + + if args.output: + output_path = args.output + else: + output_path = str(Path("/tmp") / auto_filename(config)) + + render(config, output_path) + + +if __name__ == "__main__": + main() diff --git a/content/events/designing-reusable-infrastructure-as-code/index.md b/content/events/designing-reusable-infrastructure-as-code/index.md index b9f4fe56fdd9..f60b86fcb6d0 100644 --- a/content/events/designing-reusable-infrastructure-as-code/index.md +++ b/content/events/designing-reusable-infrastructure-as-code/index.md @@ -2,7 +2,8 @@ # Name of the event, <= 60 characters title: Designing Reusable Infrastructure as Code meta_desc: "Master Pulumi Components: Learn to create reusable infrastructure code across languages, enabling DRY principles and powerful cross-team infrastructure sharing." -meta_image: +meta_image: /events/designing-reusable-infrastructure-as-code/meta.png +meta_image_square: /events/designing-reusable-infrastructure-as-code/meta-square.png # A featured webinar will display first in the list. featured: false diff --git a/content/events/designing-reusable-infrastructure-as-code/meta-square.png b/content/events/designing-reusable-infrastructure-as-code/meta-square.png new file mode 100644 index 000000000000..9cc1a283b5c5 Binary files /dev/null and b/content/events/designing-reusable-infrastructure-as-code/meta-square.png differ diff --git a/content/events/designing-reusable-infrastructure-as-code/meta.png b/content/events/designing-reusable-infrastructure-as-code/meta.png new file mode 100644 index 000000000000..9a9d3e2c724f Binary files /dev/null and b/content/events/designing-reusable-infrastructure-as-code/meta.png differ diff --git a/layouts/partials/head.html b/layouts/partials/head.html index 9cb220b7e438..2cd29433a621 100644 --- a/layouts/partials/head.html +++ b/layouts/partials/head.html @@ -80,6 +80,13 @@ <meta property="og:image" content="{{ $ogImage | absURL }}" /> + <meta property="og:image:width" content="1200" /> + <meta property="og:image:height" content="628" /> + {{ if .Params.meta_image_square }} + <meta property="og:image" content="{{ .Params.meta_image_square | absURL }}" /> + <meta property="og:image:width" content="628" /> + <meta property="og:image:height" content="628" /> + {{ end }} <meta property="og:type" content="article" /> <meta property="og:url" content="{{ .Permalink }}" /> <meta property="og:site_name" content="pulumi" />