Create Blog Post
Convert a draft markdown file into a properly formatted ESPHome blog post (Astro/Starlight MDX).
There are two kinds of blog posts this skill handles:
- Standard blog posts — full content hosted on the ESPHome website, converted from a Google Docs markdown draft.
This is the default path, described in the sections below.
- Externally linked (crosspost) blog posts — a short teaser that redirects readers to an article hosted on another
site (for example, the Open Home Foundation blog). If the user asks to "publish a blog that is externally linked",
"create a crosspost", "add an external blog post", or similar, follow the
Externally linked (crosspost) blog posts section instead.
Usage
Place your draft blog post markdown file in the repository root create-blog-post/ directory
(e.g., /workspaces/esphome-docs/create-blog-post/), then run:
/create-blog-post
What This Skill Does
Automates conversion of a draft markdown file with metadata into a production-ready ESPHome blog post:
- Extracts metadata (blog title, author, publish date, description)
- Removes "# Blog notes/preparations" section and lines with ☝️ emoji
- Removes the
### **– Summary break / Read more –** marker (Starlight has no excerpt marker)
- Processes an optional lead image and any additional images (optimized, imported via the
Image/Figure components)
- Sets the OpenGraph/Twitter image via the Open Home Foundation OG generator URL (based on the post's blog URL)
- Keeps links as Markdown (internal links relative with a trailing slash; external links as standard Markdown)
- Formats content (removes bold from headings, ensures headings start at H2, fixes link references)
- Converts callouts to GitHub-style alerts (
> [!NOTE], etc.)
- Creates a properly formatted MDX post in
src/content/docs/blog/ with Starlight front matter
Required Files in create-blog-post/ Directory
- Draft markdown file (any
.md filename)
art.* - Optional lead image shown at the top of the post (any common image format: .webp, .png, .jpg, .jpeg).
The social/OpenGraph image is generated automatically (see below) and does not need to be supplied.
image2.*, image3.*, etc. - Additional images (optional, any common image format)
Draft File Format
# Metadata
**Blog title:** Your Blog Title
**Author:** Author Name
**Publish date:** DD-MM-YYYY
**Description** (used for the page meta/SEO description, ~120-158 characters):
Concise summary that describes what readers will find. Include the main keyword.
# Blog notes/preparations
☝️ Any lines with the pointer emoji can be removed during processing
# Blog content
![][image1]
Your intro paragraph here...
### **– Summary break / Read more –**
Rest of content...
Notes:
- If present, the
![][image1] reference at the start of the "# Blog content" section is replaced with the optional
art.* lead image. If there is no lead image, the post simply starts with text.
- The URL slug is optional and will be auto-generated from the blog title if not provided in metadata.
- Lines beginning with the ☝️ emoji are instructions and will be removed during processing.
- The
### **– Summary break / Read more –** marker will be removed (there is no Jekyll-style excerpt on this site).
Output
Creates a production-ready blog post at:
src/content/docs/blog/YYYY/MM/DD/<slug>.mdx - The formatted MDX blog post
src/content/docs/blog/YYYY/MM/DD/images/<slug>-hero.webp - Optional lead image (optimized from create-blog-post/art.*, if provided)
src/content/docs/blog/YYYY/MM/DD/images/<slug>-2.webp, <slug>-3.webp, etc. - Additional images (optimized)
The social/OpenGraph image is not stored in the repo — it is generated on demand by the Open Home Foundation OG
generator and referenced from the post's front matter (see Build Blog Post).
Conversion Process
1. Pre-process Draft
Before doing anything else, strip out embedded base64 image data from the draft file using a shell command.
Do not read the draft file before this step — the base64 data can make the file extremely large.
Google Docs markdown exports include image references like ![][image1] in the content body, with corresponding
base64 definitions at the bottom of the file in the format:
[image1]: <data:image/png;base64,iVBORw0KGgo... (potentially megabytes of data)>
Run this sed command via the terminal to strip them in-place:
sed -i '/^\[image[0-9]*\]: <data:/d' "create-blog-post/draft.md"
- This removes all lines matching the base64 image definition pattern.
- The
![][image1] references in the content body are preserved — they will be replaced with proper image references later.
- Only after this command completes should you read the draft file.
2. Parse Metadata
- Extract blog title, author, publish date, and description.
- Auto-generate the URL slug from the blog title (lowercase, hyphens for spaces, remove special characters).
- Remove the "# Blog notes/preparations" section and all content under it (up to "# Blog content").
- Remove all lines that start with the ☝️ emoji (instruction lines).
- Remove the
### **– Summary break / Read more –** marker entirely.
3. Process Images
ESPHome follows the image conventions in CONTRIBUTING.md: single-use blog images are stored
in a local images/ directory next to the MDX file, optimized, and rendered through the Image or Figure component
via an import.
Before processing images, ensure the cwebp tool is installed. If not, install it:
# Check if cwebp is available, install if missing
which cwebp || sudo apt-get install -y webp
Lead image (art.*, optional):
This is only the in-page lead image at the top of the post — it is not the social/OpenGraph image (that is
generated via a URL; see Build Blog Post). Skip this section if no art.* file is provided.
Find the art image in create-blog-post/ (any extension: .webp, .png, .jpg, .jpeg).
Optimize and convert to WebP at a maximum width of 1000px (per CONTRIBUTING.md — max ~1000x800):
cwebp -resize 1000 0 -q 85 input -o src/content/docs/blog/images/<slug>-hero.webp (the 0 height preserves the aspect ratio).
If the source is already .webp, still re-encode it with the resize to keep it small.
At the top of the MDX file (after the front matter) add the imports:
import { Image } from 'astro:assets';
import heroImg from './images/<slug>-hero.webp';
Replace the ![][image1] reference in the "# Blog content" section with:
<Image src={heroImg} alt="Blog Title" layout="constrained" />
Alt text uses the blog title.
Additional images (if any):
Find image2.*, image3.*, etc. in create-blog-post/ (any extension).
Optimize and convert to WebP at a maximum width of 900px:
cwebp -resize 900 0 -q 85 input -o src/content/docs/blog/images/<slug>-2.webp.
Import each one at the top of the MDX file:
import image2Img from './images/<slug>-2.webp';
Replace the corresponding reference in the content. If the image has a caption, use the Figure component:
import Figure from '@components/Figure.astro';
<Figure src={image2Img} alt="Description" caption="Optional caption text" />
Otherwise use Image:
<Image src={image2Img} alt="Description" layout="constrained" />
Variable names follow camelCase with an Img suffix (e.g., heroImg, image2Img).
4. Transform Links
Per CONTRIBUTING.md, prefer Markdown over raw HTML. Keep all links as Markdown links.
Internal links (esphome.io):
- Convert to relative Markdown links starting with
/ and ending with a trailing slash: [text](/components/wifi/).
External links (any other domain):
- Keep as standard Markdown links:
[text](https://example.com/).
- Do not convert to raw
<a target="_blank"> tags.
5. Clean Content
- Headings: Remove bold formatting (
## **Title** → ## Title).
- Heading levels: Content must start at H2. The page
title comes from the front matter — do not add an H1 (#)
in the body. If the content starts with an H1, demote all headings one level so the body begins at H2.
- Backticks: Strip erroneous escaped backtick (
\`) characters (preserve real code blocks / inline code).
- Callouts: Convert any blockquote-style notes/warnings to GitHub-style alerts
(
> [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION]).
- Line length: Wrap prose at a maximum of 120 characters where practical.
- Text content: Do not change the author's wording, phrasing, or writing style. If you spot obvious typos or locale
spelling issues (such as British English instead of American English — the docs use American English), do not fix them
silently. Collect them and ask the user for confirmation before applying any changes.
- Emojis: Preserve all emojis that appear in the blog content. Do not strip them out.
- Apostrophes and quotes: Convert straight apostrophes (
') and speech marks (") in prose — including quoted
statements, such as in blockquotes — to their curly equivalents (’, “/”). Only apply this to body text — never
to front matter, HTML attribute values, URLs, code blocks/inline code, component props, or import statements.
6. Build Blog Post
Create src/content/docs/blog/<slug>.mdx.
Add Starlight front matter. title and description are required and must be quoted strings. Also set the
social/OpenGraph image via the OG generator, using the post's blog URL (https://esphome.io/blog/<slug>):
---
title: "Your Blog Title"
description: "The description from the draft metadata."
head:
- tag: meta
attrs:
property: "og:image"
content: "https://assets.openhomefoundation.org/opengraph?url=https://esphome.io/blog/<slug>"
- tag: meta
attrs:
name: "twitter:image"
content: "https://assets.openhomefoundation.org/opengraph?url=https://esphome.io/blog/<slug>"
---
These head entries override the site-wide default OG image (set in astro.config.mjs) for this post. The generator
renders the OpenGraph image on demand from the post's URL, so no image file needs to be created or committed. Always
reference this as a remote URL — never import it through Astro (astro:assets/import), because the generator output
can change and must not be fingerprinted or cached. Set the same URL on the cover.image front-matter field too.
After the front matter, add the component imports (Image, Figure as needed, and each image import).
Add a byline line with the author and publish date near the top of the body (there is no author/date field in the
Starlight docs schema, so this is rendered as content), for example:
*By Author Name · 13 January 2026*
If a lead image was provided, add it (<Image src={heroImg} ... />).
Add the intro paragraph, followed by the remaining content.
Example
- Place in the repository root
create-blog-post/:
draft-new-feature.md - Your draft file
art.png - Hero image
image2.png, image3.png - Additional images (if any)
- Run
/create-blog-post
This would create:
src/content/docs/blog/new-feature.mdx
src/content/docs/blog/images/new-feature-hero.webp
src/content/docs/blog/images/new-feature-2.webp, new-feature-3.webp (if additional images exist)
Important Notes
Image references:
- Draft:
![][image1] (at the start of the "# Blog content" section, if present) → Output: optional <slug>-hero.webp lead image (max 1000px wide).
- Draft:
![][image2] → Look for image2.* (any format), optimize to <slug>-2.webp (max 900px wide).
- Draft:
![][image3] → Look for image3.* (any format), optimize to <slug>-3.webp (max 900px wide).
- Source images can be any common format (
.webp, .png, .jpg, .jpeg) — all are optimized to .webp.
- All blog images live in
src/content/docs/blog/images/ and are referenced via import and the Image/Figure component.
Requirements:
- If a lead image is provided, its reference should appear at the start of the "# Blog content" section.
cwebp is required for optimization — the skill will auto-install it via sudo apt-get install -y webp if not present.
- The OpenGraph image requires no source file; it is generated from the post URL.
Content processing:
- Remove the "# Blog notes/preparations" section entirely.
- Remove all lines starting with the ☝️ emoji (instruction lines).
- Remove the
### **– Summary break / Read more –** marker.
- Convert callouts to GitHub-style alerts.
Output format:
- Filename:
src/content/docs/blog/<slug>.mdx.
- Image directory:
src/content/docs/blog/images/.
- Front matter: quoted
title and description. Do not repeat the title as an H1 in the body; body starts at H2.
OpenGraph / cover image:
- Always reference a remote URL. Never import the OpenGraph or
cover.image through Astro (no local file,
no import, no astro:assets). These images are served live from a remote generator whose output can change over
time, so Astro must not fingerprint, optimize, or cache them.
- Standard posts: use the Open Home Foundation OG generator, which renders the image on demand from the post URL:
https://assets.openhomefoundation.org/opengraph?url=https://esphome.io/blog/<slug>. Set it on both the
cover.image field and the og:image / twitter:image head meta tags. No source file is created or committed.
- Crossposts: use the source site's own card image instead of the generator (for example, the Open Home
Foundation site card at
https://www.openhomefoundation.org/assets/images/blog/<slug>/card.webp) — again as a remote
URL only, set on og:image, twitter:image, and the crosspostCover field (never the plugin's cover).
Link handling:
- Internal
esphome.io links → relative Markdown links with a trailing slash.
- External links → standard Markdown links (no raw
<a target="_blank">).
Externally linked (crosspost) blog posts
A crosspost is a short blog entry that does not host the full article. It shows a teaser and redirects the reader to an
article hosted on another site (for example, the Open Home Foundation blog). Use this path when the user asks to publish
an externally linked blog, add a crosspost, or link out to an article on another site.
Unlike a standard post, a crosspost has no draft file and no local images to process. It is a single MDX file whose
front matter drives the redirect, the canonical URL, the social image, and the "shared from" badge. See the existing
example at src/content/docs/blog/2026/07/30/making-our-web-analytics-open-source-with-plausible.mdx.
1. Collect the details
Gather the following from the user with the ask-questions tool (vscode_askQuestions), pre-filling any values already
provided. If a source URL is available, fetch it first to pre-fill the title, description, teaser, author, and date.
- Title — sentence-style capitalization.
- External URL — the final published URL on the source site (must start with
https://). If the user only has a
deploy-preview link (for example a Netlify preview), work out the live URL and confirm it before continuing — the
canonical link, the redirect, and the social image all depend on it.
- External source — the name of the hosting site, shown on the badge (for example,
Open Home Foundation). Stored
in the crosspostSource field.
- Teaser — a short opening paragraph. Used as both the
excerpt and the page body. Offer to draft one from the
article for the user's review.
- Description — the SEO/OpenGraph description (~120–158 characters).
- Author — must match a top-level key in
src/authors.mjs (for example darren or
jesse). Verify it exists; if not, it must be added there before publishing.
- Publish date — in
YYYY-MM-DD format. Used for the filename path, date, and ordering.
- Category/tag — the blog tag (for example,
Announcements).
- Social image — by default point
og:image, twitter:image, and crosspostCover at the source article's card
image. For Open Home Foundation articles this is usually
https://www.openhomefoundation.org/assets/images/blog/<slug>/card.webp. Confirm the URL resolves. Note the crosspost
card image uses the dedicated crosspostCover field (not the plugin's cover) so it renders as a plain <img> — see
the note under Build the crosspost.
2. Validate the details
- Verify the author exists as a top-level key in
src/authors.mjs. If missing, stop and ask the user to add it.
- Verify the external URL starts with
https:// and is the final published URL, not a preview/deploy-preview link.
- Generate the URL slug from the title (lowercase, hyphens for spaces, remove special characters), unless the user
provides one. If the source URL already has a clean slug in its path, prefer reusing that.
3. Build the crosspost
Create the file in the same dated location as a standard post, using the publish date:
src/content/docs/blog/YYYY/MM/DD/<slug>.mdx.
---
head:
- tag: link
attrs:
rel: "canonical"
href: "<EXTERNAL_URL>"
- tag: meta
attrs:
http-equiv: "refresh"
content: "0; url=<EXTERNAL_URL>"
- tag: script
content: |
window.location.replace("<EXTERNAL_URL>");
- tag: meta
attrs:
property: "og:image"
content: "<SOCIAL_IMAGE_URL>"
- tag: meta
attrs:
name: "twitter:image"
content: "<SOCIAL_IMAGE_URL>"
- tag: meta
attrs:
property: "og:image:alt"
content: "<TITLE>"
title: "<TITLE>"
description: "<DESCRIPTION>"
crosspostSource: "<EXTERNAL SOURCE>"
crosspostCover: "<SOCIAL_IMAGE_URL>"
excerpt: "<TEASER PARAGRAPH>"
date: YYYY-MM-DD
authors:
- <authorKey>
tags:
- <Category>
---
<TEASER PARAGRAPH>
Notes:
- Keep all three redirect
head entries: the canonical link, the http-equiv="refresh" meta, and the
window.location.replace script. Together they send visitors to the source article and tell search engines the
canonical version lives off-site.
crosspostSource opts the post into the "shared from <source>" pill on the blog grid (see
CrosspostBadges.astro). It must be the plain source name only.
crosspostCover is the blog-grid card image — point it at the same <SOCIAL_IMAGE_URL> as og:image. Never use the
starlight-blog cover field for a crosspost: that renders through Astro's <Image>, which probes and caches the
remote image at build time (and fails if the source image is not live yet). crosspostCover is drawn as a plain
<img> client-side, so it updates the moment the source image is published — no rebuild needed.
- The body is only the teaser paragraph (the same text as
excerpt). Do not paste the full article — the reader is
redirected on load.
- Apply the same prose rules as standard posts (curly apostrophes/quotes in body text, sentence-style title).
- Do not add a byline or any comments markup: the page redirects immediately, so on-page content beyond the teaser is
never seen.
4. Crosspost summary
After creating the file, summarize for the user:
- The output file path.
- Title, external source, external URL, author (and whether verified in
src/authors.mjs), date, and tag.
- The teaser and description used, and the social image URL.
- A note that the page sets its canonical to the external URL, redirects to the source on load, and shows the
"shared from" badge on the blog index.
Git Workflow
Per CONTRIBUTING.md, target the correct base branch and use a focused branch/commit:
- New content generally targets the
current branch; new-feature content that ships with an unreleased ESPHome version
targets next. Confirm with the user which branch applies.
- Branch from the target:
git checkout -b blog-<slug> current.
- Commit message format:
[blog] Add <slug> post.
Post-processing summary
After the blog post has been created, output a summary to the user covering:
Metadata:
- Title, author, publish date, description.
Images:
- Each source image, its original dimensions/format, and where it was output (with the optimization applied).
- The OpenGraph image URL set in the front matter (generated via the OG generator).
Content transformations:
- A bulleted list of every notable transformation applied, such as:
- Sections/content removed (base64 data, blog notes, instruction lines, summary-break marker)
- Image references replaced with
Image/Figure components
- Link handling (internal to relative Markdown with trailing slash; external left as Markdown)
- Callouts converted to GitHub-style alerts
- Heading changes (bold removed, promoted/demoted to start at H2)
- Escape-character cleanup
- Apostrophe/quote curling (straight to typographic)
Proposed text changes (requires user approval):
- If any typos or locale spelling issues were spotted (such as British to American English), list each one and ask the
user whether to apply them. Do not apply these changes until the user confirms.
1---2name: create-blog-post3description: Use this if the user wants to convert a blog post from Google Docs markdown to the format used on the ESPHome website, or wants to publish an externally linked (crosspost) blog post that redirects to an article hosted on another site.4---56# Create Blog Post78Convert a draft markdown file into a properly formatted ESPHome blog post (Astro/Starlight MDX).910There are two kinds of blog posts this skill handles:1112- **Standard blog posts** — full content hosted on the ESPHome website, converted from a Google Docs markdown draft.13 This is the default path, described in the sections below.14- **Externally linked (crosspost) blog posts** — a short teaser that redirects readers to an article hosted on another15 site (for example, the Open Home Foundation blog). If the user asks to "publish a blog that is externally linked",16 "create a crosspost", "add an external blog post", or similar, follow the17 [Externally linked (crosspost) blog posts](#externally-linked-crosspost-blog-posts) section instead.1819## Usage2021Place your draft blog post markdown file in the repository root `create-blog-post/` directory22(e.g., `/workspaces/esphome-docs/create-blog-post/`), then run:2324```shell25/create-blog-post26```2728## What This Skill Does2930Automates conversion of a draft markdown file with metadata into a production-ready ESPHome blog post:3132- Extracts metadata (blog title, author, publish date, description)33- Removes "# Blog notes/preparations" section and lines with ☝️ emoji34- Removes the `### **– Summary break / Read more –**` marker (Starlight has no excerpt marker)35- Processes an optional lead image and any additional images (optimized, imported via the `Image`/`Figure` components)36- Sets the OpenGraph/Twitter image via the Open Home Foundation OG generator URL (based on the post's blog URL)37- Keeps links as Markdown (internal links relative with a trailing slash; external links as standard Markdown)38- Formats content (removes bold from headings, ensures headings start at H2, fixes link references)39- Converts callouts to GitHub-style alerts (`> [!NOTE]`, etc.)40- Creates a properly formatted MDX post in `src/content/docs/blog/` with Starlight front matter4142## Required Files in `create-blog-post/` Directory43441. **Draft markdown file** (any `.md` filename)451. **`art.*`** - Optional lead image shown at the top of the post (any common image format: `.webp`, `.png`, `.jpg`, `.jpeg`).46 The social/OpenGraph image is generated automatically (see below) and does not need to be supplied.471. **`image2.*`, `image3.*`, etc.** - Additional images (optional, any common image format)4849## Draft File Format5051```markdown52# Metadata5354**Blog title:** Your Blog Title5556**Author:** Author Name5758**Publish date:** DD-MM-YYYY5960**Description** (used for the page meta/SEO description, ~120-158 characters):61Concise summary that describes what readers will find. Include the main keyword.6263# Blog notes/preparations6465☝️ Any lines with the pointer emoji can be removed during processing6667# Blog content6869![][image1]7071Your intro paragraph here...7273### **– Summary break / Read more –**7475Rest of content...76```7778**Notes:**7980- If present, the `![][image1]` reference at the start of the "# Blog content" section is replaced with the optional81 `art.*` lead image. If there is no lead image, the post simply starts with text.82- The URL slug is optional and will be auto-generated from the blog title if not provided in metadata.83- Lines beginning with the ☝️ emoji are instructions and will be removed during processing.84- The `### **– Summary break / Read more –**` marker will be removed (there is no Jekyll-style excerpt on this site).8586## Output8788Creates a production-ready blog post at:8990- `src/content/docs/blog/YYYY/MM/DD/<slug>.mdx` - The formatted MDX blog post91- `src/content/docs/blog/YYYY/MM/DD/images/<slug>-hero.webp` - Optional lead image (optimized from `create-blog-post/art.*`, if provided)92- `src/content/docs/blog/YYYY/MM/DD/images/<slug>-2.webp`, `<slug>-3.webp`, etc. - Additional images (optimized)9394The social/OpenGraph image is not stored in the repo — it is generated on demand by the Open Home Foundation OG95generator and referenced from the post's front matter (see [Build Blog Post](#6-build-blog-post)).9697## Conversion Process9899### 1. Pre-process Draft100101Before doing anything else, strip out embedded base64 image data from the draft file using a shell command.102**Do not read the draft file before this step** — the base64 data can make the file extremely large.103104Google Docs markdown exports include image references like `![][image1]` in the content body, with corresponding105base64 definitions at the bottom of the file in the format:106107```text108[image1]: <data:image/png;base64,iVBORw0KGgo... (potentially megabytes of data)>109```110111Run this `sed` command via the terminal to strip them in-place:112113```shell114sed -i '/^\[image[0-9]*\]: <data:/d' "create-blog-post/draft.md"115```116117- This removes all lines matching the base64 image definition pattern.118- The `![][image1]` references in the content body are preserved — they will be replaced with proper image references later.119- Only after this command completes should you read the draft file.120121### 2. Parse Metadata122123- Extract blog title, author, publish date, and description.124- Auto-generate the URL slug from the blog title (lowercase, hyphens for spaces, remove special characters).125- Remove the "# Blog notes/preparations" section and all content under it (up to "# Blog content").126- Remove all lines that start with the ☝️ emoji (instruction lines).127- Remove the `### **– Summary break / Read more –**` marker entirely.128129### 3. Process Images130131ESPHome follows the image conventions in [CONTRIBUTING.md](../../../CONTRIBUTING.md): single-use blog images are stored132in a local `images/` directory next to the MDX file, optimized, and rendered through the `Image` or `Figure` component133via an `import`.134135Before processing images, ensure the `cwebp` tool is installed. If not, install it:136137```shell138# Check if cwebp is available, install if missing139which cwebp || sudo apt-get install -y webp140```141142**Lead image (`art.*`, optional):**143144This is only the in-page lead image at the top of the post — it is **not** the social/OpenGraph image (that is145generated via a URL; see [Build Blog Post](#6-build-blog-post)). Skip this section if no `art.*` file is provided.146147- Find the `art` image in `create-blog-post/` (any extension: `.webp`, `.png`, `.jpg`, `.jpeg`).148- Optimize and convert to WebP at a maximum width of 1000px (per CONTRIBUTING.md — max ~1000x800):149 `cwebp -resize 1000 0 -q 85 input -o src/content/docs/blog/images/<slug>-hero.webp` (the `0` height preserves the aspect ratio).150- If the source is already `.webp`, still re-encode it with the resize to keep it small.151- At the top of the MDX file (after the front matter) add the imports:152153 ```mdx154 import { Image } from 'astro:assets';155 import heroImg from './images/<slug>-hero.webp';156 ```157158- Replace the `![][image1]` reference in the "# Blog content" section with:159160 ```mdx161 <Image src={heroImg} alt="Blog Title" layout="constrained" />162 ```163164- Alt text uses the blog title.165166**Additional images (if any):**167168- Find `image2.*`, `image3.*`, etc. in `create-blog-post/` (any extension).169- Optimize and convert to WebP at a maximum width of 900px:170 `cwebp -resize 900 0 -q 85 input -o src/content/docs/blog/images/<slug>-2.webp`.171- Import each one at the top of the MDX file:172173 ```mdx174 import image2Img from './images/<slug>-2.webp';175 ```176177- Replace the corresponding reference in the content. If the image has a caption, use the `Figure` component:178179 ```mdx180 import Figure from '@components/Figure.astro';181182 <Figure src={image2Img} alt="Description" caption="Optional caption text" />183 ```184185 Otherwise use `Image`:186187 ```mdx188 <Image src={image2Img} alt="Description" layout="constrained" />189 ```190191- Variable names follow camelCase with an `Img` suffix (e.g., `heroImg`, `image2Img`).192193### 4. Transform Links194195Per CONTRIBUTING.md, prefer Markdown over raw HTML. Keep all links as Markdown links.196197**Internal links** (`esphome.io`):198199- Convert to relative Markdown links starting with `/` and ending with a trailing slash: `[text](/components/wifi/)`.200201**External links** (any other domain):202203- Keep as standard Markdown links: `[text](https://example.com/)`.204- Do **not** convert to raw `<a target="_blank">` tags.205206### 5. Clean Content207208- **Headings**: Remove bold formatting (`## **Title**` → `## Title`).209- **Heading levels**: Content must start at H2. The page `title` comes from the front matter — do not add an H1 (`#`)210 in the body. If the content starts with an H1, demote all headings one level so the body begins at H2.211- **Backticks**: Strip erroneous escaped backtick (`` \` ``) characters (preserve real code blocks / inline code).212- **Callouts**: Convert any blockquote-style notes/warnings to GitHub-style alerts213 (`> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`).214- **Line length**: Wrap prose at a maximum of 120 characters where practical.215- **Text content**: Do not change the author's wording, phrasing, or writing style. If you spot obvious typos or locale216 spelling issues (such as British English instead of American English — the docs use American English), do not fix them217 silently. Collect them and ask the user for confirmation before applying any changes.218- **Emojis**: Preserve all emojis that appear in the blog content. Do not strip them out.219- **Apostrophes and quotes**: Convert straight apostrophes (`'`) and speech marks (`"`) in prose — including quoted220 statements, such as in blockquotes — to their curly equivalents (`’`, `“`/`”`). Only apply this to body text — never221 to front matter, HTML attribute values, URLs, code blocks/inline code, component props, or import statements.222223### 6. Build Blog Post224225- Create `src/content/docs/blog/<slug>.mdx`.226- Add Starlight front matter. `title` and `description` are required and must be quoted strings. Also set the227 social/OpenGraph image via the OG generator, using the post's blog URL (`https://esphome.io/blog/<slug>`):228229 ```yaml230 ---231 title: "Your Blog Title"232 description: "The description from the draft metadata."233 head:234 - tag: meta235 attrs:236 property: "og:image"237 content: "https://assets.openhomefoundation.org/opengraph?url=https://esphome.io/blog/<slug>"238 - tag: meta239 attrs:240 name: "twitter:image"241 content: "https://assets.openhomefoundation.org/opengraph?url=https://esphome.io/blog/<slug>"242 ---243 ```244245 These `head` entries override the site-wide default OG image (set in `astro.config.mjs`) for this post. The generator246 renders the OpenGraph image on demand from the post's URL, so no image file needs to be created or committed. Always247 reference this as a remote URL — never import it through Astro (`astro:assets`/`import`), because the generator output248 can change and must not be fingerprinted or cached. Set the same URL on the `cover.image` front-matter field too.249250- After the front matter, add the component imports (`Image`, `Figure` as needed, and each image import).251- Add a byline line with the author and publish date near the top of the body (there is no author/date field in the252 Starlight docs schema, so this is rendered as content), for example:253254 ```mdx255 *By Author Name · 13 January 2026*256 ```257258- If a lead image was provided, add it (`<Image src={heroImg} ... />`).259- Add the intro paragraph, followed by the remaining content.260261## Example2622631. Place in the repository root `create-blog-post/`:264 - `draft-new-feature.md` - Your draft file265 - `art.png` - Hero image266 - `image2.png`, `image3.png` - Additional images (if any)2671. Run `/create-blog-post`268269This would create:270271- `src/content/docs/blog/new-feature.mdx`272- `src/content/docs/blog/images/new-feature-hero.webp`273- `src/content/docs/blog/images/new-feature-2.webp`, `new-feature-3.webp` (if additional images exist)274275## Important Notes276277**Image references:**278279- Draft: `![][image1]` (at the start of the "# Blog content" section, if present) → Output: optional `<slug>-hero.webp` lead image (max 1000px wide).280- Draft: `![][image2]` → Look for `image2.*` (any format), optimize to `<slug>-2.webp` (max 900px wide).281- Draft: `![][image3]` → Look for `image3.*` (any format), optimize to `<slug>-3.webp` (max 900px wide).282- Source images can be any common format (`.webp`, `.png`, `.jpg`, `.jpeg`) — all are optimized to `.webp`.283- All blog images live in `src/content/docs/blog/images/` and are referenced via `import` and the `Image`/`Figure` component.284285**Requirements:**286287- If a lead image is provided, its reference should appear at the start of the "# Blog content" section.288- `cwebp` is required for optimization — the skill will auto-install it via `sudo apt-get install -y webp` if not present.289- The OpenGraph image requires no source file; it is generated from the post URL.290291**Content processing:**292293- Remove the "# Blog notes/preparations" section entirely.294- Remove all lines starting with the ☝️ emoji (instruction lines).295- Remove the `### **– Summary break / Read more –**` marker.296- Convert callouts to GitHub-style alerts.297298**Output format:**299300- Filename: `src/content/docs/blog/<slug>.mdx`.301- Image directory: `src/content/docs/blog/images/`.302- Front matter: quoted `title` and `description`. Do not repeat the title as an H1 in the body; body starts at H2.303304**OpenGraph / cover image:**305306- **Always reference a remote URL.** Never import the OpenGraph or `cover.image` through Astro (no local file,307 no `import`, no `astro:assets`). These images are served live from a remote generator whose output can change over308 time, so Astro must not fingerprint, optimize, or cache them.309- **Standard posts:** use the Open Home Foundation OG generator, which renders the image on demand from the post URL:310 `https://assets.openhomefoundation.org/opengraph?url=https://esphome.io/blog/<slug>`. Set it on both the311 `cover.image` field and the `og:image` / `twitter:image` `head` meta tags. No source file is created or committed.312- **Crossposts:** use the source site's own card image instead of the generator (for example, the Open Home313 Foundation site card at `https://www.openhomefoundation.org/assets/images/blog/<slug>/card.webp`) — again as a remote314 URL only, set on `og:image`, `twitter:image`, and the `crosspostCover` field (never the plugin's `cover`).315316**Link handling:**317318- Internal `esphome.io` links → relative Markdown links with a trailing slash.319- External links → standard Markdown links (no raw `<a target="_blank">`).320321## Externally linked (crosspost) blog posts322323A crosspost is a short blog entry that does not host the full article. It shows a teaser and redirects the reader to an324article hosted on another site (for example, the Open Home Foundation blog). Use this path when the user asks to publish325an externally linked blog, add a crosspost, or link out to an article on another site.326327Unlike a standard post, a crosspost has no draft file and no local images to process. It is a single MDX file whose328front matter drives the redirect, the canonical URL, the social image, and the "shared from" badge. See the existing329example at `src/content/docs/blog/2026/07/30/making-our-web-analytics-open-source-with-plausible.mdx`.330331### 1. Collect the details332333Gather the following from the user with the ask-questions tool (`vscode_askQuestions`), pre-filling any values already334provided. If a source URL is available, fetch it first to pre-fill the title, description, teaser, author, and date.335336- **Title** — sentence-style capitalization.337- **External URL** — the final published URL on the source site (must start with `https://`). If the user only has a338 deploy-preview link (for example a Netlify preview), work out the live URL and confirm it before continuing — the339 canonical link, the redirect, and the social image all depend on it.340- **External source** — the name of the hosting site, shown on the badge (for example, `Open Home Foundation`). Stored341 in the `crosspostSource` field.342- **Teaser** — a short opening paragraph. Used as both the `excerpt` and the page body. Offer to draft one from the343 article for the user's review.344- **Description** — the SEO/OpenGraph description (~120–158 characters).345- **Author** — must match a top-level key in [`src/authors.mjs`](../../../src/authors.mjs) (for example `darren` or346 `jesse`). Verify it exists; if not, it must be added there before publishing.347- **Publish date** — in `YYYY-MM-DD` format. Used for the filename path, `date`, and ordering.348- **Category/tag** — the blog tag (for example, `Announcements`).349- **Social image** — by default point `og:image`, `twitter:image`, and `crosspostCover` at the source article's card350 image. For Open Home Foundation articles this is usually351 `https://www.openhomefoundation.org/assets/images/blog/<slug>/card.webp`. Confirm the URL resolves. Note the crosspost352 card image uses the dedicated `crosspostCover` field (not the plugin's `cover`) so it renders as a plain `<img>` — see353 the note under [Build the crosspost](#3-build-the-crosspost).354355### 2. Validate the details356357- Verify the **author** exists as a top-level key in `src/authors.mjs`. If missing, stop and ask the user to add it.358- Verify the **external URL** starts with `https://` and is the final published URL, not a preview/deploy-preview link.359- Generate the URL slug from the title (lowercase, hyphens for spaces, remove special characters), unless the user360 provides one. If the source URL already has a clean slug in its path, prefer reusing that.361362### 3. Build the crosspost363364Create the file in the same dated location as a standard post, using the publish date:365`src/content/docs/blog/YYYY/MM/DD/<slug>.mdx`.366367```mdx368---369head:370 - tag: link371 attrs:372 rel: "canonical"373 href: "<EXTERNAL_URL>"374 - tag: meta375 attrs:376 http-equiv: "refresh"377 content: "0; url=<EXTERNAL_URL>"378 - tag: script379 content: |380 window.location.replace("<EXTERNAL_URL>");381 - tag: meta382 attrs:383 property: "og:image"384 content: "<SOCIAL_IMAGE_URL>"385 - tag: meta386 attrs:387 name: "twitter:image"388 content: "<SOCIAL_IMAGE_URL>"389 - tag: meta390 attrs:391 property: "og:image:alt"392 content: "<TITLE>"393title: "<TITLE>"394description: "<DESCRIPTION>"395crosspostSource: "<EXTERNAL SOURCE>"396crosspostCover: "<SOCIAL_IMAGE_URL>"397excerpt: "<TEASER PARAGRAPH>"398date: YYYY-MM-DD399authors:400 - <authorKey>401tags:402 - <Category>403---404405<TEASER PARAGRAPH>406```407408Notes:409410- Keep all three redirect `head` entries: the canonical `link`, the `http-equiv="refresh"` meta, and the411 `window.location.replace` script. Together they send visitors to the source article and tell search engines the412 canonical version lives off-site.413- `crosspostSource` opts the post into the "shared from <source>" pill on the blog grid (see414 [`CrosspostBadges.astro`](../../../src/components/CrosspostBadges.astro)). It must be the plain source name only.415- `crosspostCover` is the blog-grid card image — point it at the same `<SOCIAL_IMAGE_URL>` as `og:image`. Never use the416 starlight-blog `cover` field for a crosspost: that renders through Astro's `<Image>`, which probes and caches the417 remote image at build time (and fails if the source image is not live yet). `crosspostCover` is drawn as a plain418 `<img>` client-side, so it updates the moment the source image is published — no rebuild needed.419- The body is only the teaser paragraph (the same text as `excerpt`). Do not paste the full article — the reader is420 redirected on load.421- Apply the same prose rules as standard posts (curly apostrophes/quotes in body text, sentence-style title).422- Do not add a byline or any comments markup: the page redirects immediately, so on-page content beyond the teaser is423 never seen.424425### 4. Crosspost summary426427After creating the file, summarize for the user:428429- The output file path.430- Title, external source, external URL, author (and whether verified in `src/authors.mjs`), date, and tag.431- The teaser and description used, and the social image URL.432- A note that the page sets its canonical to the external URL, redirects to the source on load, and shows the433 "shared from" badge on the blog index.434435## Git Workflow436437Per CONTRIBUTING.md, target the correct base branch and use a focused branch/commit:438439- New content generally targets the `current` branch; new-feature content that ships with an unreleased ESPHome version440 targets `next`. Confirm with the user which branch applies.441- Branch from the target: `git checkout -b blog-<slug> current`.442- Commit message format: `[blog] Add <slug> post`.443444## Post-processing summary445446After the blog post has been created, output a summary to the user covering:447448**Metadata:**449450- Title, author, publish date, description.451452**Images:**453454- Each source image, its original dimensions/format, and where it was output (with the optimization applied).455- The OpenGraph image URL set in the front matter (generated via the OG generator).456457**Content transformations:**458459- A bulleted list of every notable transformation applied, such as:460 - Sections/content removed (base64 data, blog notes, instruction lines, summary-break marker)461 - Image references replaced with `Image`/`Figure` components462 - Link handling (internal to relative Markdown with trailing slash; external left as Markdown)463 - Callouts converted to GitHub-style alerts464 - Heading changes (bold removed, promoted/demoted to start at H2)465 - Escape-character cleanup466 - Apostrophe/quote curling (straight to typographic)467468**Proposed text changes (requires user approval):**469470- If any typos or locale spelling issues were spotted (such as British to American English), list each one and ask the471 user whether to apply them. Do not apply these changes until the user confirms.