From b52cffb6dca82ff62bae561876a9e2a240a3cf05 Mon Sep 17 00:00:00 2001 From: Stanislav Zhuk Date: Mon, 26 Oct 2026 16:38:06 +0200 Subject: [PATCH 1/5] fix(blog): render SVG feature images to PNG for og:image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Short Summary (TL;DR) Social networks don't show SVG in `og:image`, so blog posts with an SVG feature image had no preview image when shared. The build now renders a PNG copy of each one and uses it for `og:image`. ## How This PR Solves The Issue A new endpoint, `src/pages/img/blog/[...path].png.js`, uses `sharp` to render each SVG feature image under `/img/blog/` to a 1200px wide PNG at the same path, so `banner.svg` gets `banner.png` next to it in `dist/` and no image files are committed. The blog post page uses that PNG for `og:image` and the JSON-LD `image`, while PNG and JPG feature images stay as they were. The build fails if a PNG with the same name already exists in `public/`, because it would clash with the generated file. ## Manual Testing Instructions - Open https://20261026-stasadev-og-png.ddev-com-front-end.pages.dev/img/blog/2026/09/banner-ddev-v1-25-4.png and check that it shows the "DDEV v1.25.4 Release" text, not only the logo. SVG text needs fonts in the Cloudflare build image, which is not verified yet. - View the source of https://20261026-stasadev-og-png.ddev-com-front-end.pages.dev/blog/release-v1-25-4/ and check that `og:image` ends in `banner-ddev-v1-25-4.png`, while https://ddev.com/blog/release-v1-25-4/ still has the `.svg`. - Paste the preview post URL into the LinkedIn Post Inspector or the Facebook Sharing Debugger and check that the image appears. 🤖 Developed with assistance from [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/blog-images/SKILL.md | 4 ++- src/pages/blog/[id].astro | 12 ++++++--- src/pages/img/blog/[...path].png.js | 42 +++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 src/pages/img/blog/[...path].png.js diff --git a/.claude/skills/blog-images/SKILL.md b/.claude/skills/blog-images/SKILL.md index 204ab757..1cfdae5b 100644 --- a/.claude/skills/blog-images/SKILL.md +++ b/.claude/skills/blog-images/SKILL.md @@ -56,7 +56,9 @@ WebP. Follow [gif-comparison.md](gif-comparison.md) in this directory. An illustration can be a hand-written SVG referenced directly from `featureImage.src`. Generate it from a script kept in `~/tmp`, not the repository, preview it with `rsvg-convert -w 1600 -o out.png in.svg`, and -install only the `.svg`. +install only the `.svg`. The build renders a PNG copy of it for `og:image`, +since social networks don't show SVG there, and fails if a PNG with the same +name is already next to it. Blog listing cards crop every feature image to 3:2 (`BlogPostCard.astro`), so prefer a 3:2 canvas. On a wider one, keep anything that must stay visible diff --git a/src/pages/blog/[id].astro b/src/pages/blog/[id].astro index 84f9c81a..7d855b98 100644 --- a/src/pages/blog/[id].astro +++ b/src/pages/blog/[id].astro @@ -40,6 +40,12 @@ const shouldDisplayFeatureImage = entry.data.featureImage?.hide === false) const authorImage = author?.data.avatarUrl +// Social networks don't render SVG, so use the PNG from img/blog/[...path].png.js +let ogImage = entry.data.featureImage?.src ?? null +if (ogImage?.startsWith("/img/blog/") && ogImage.endsWith(".svg")) { + ogImage = ogImage.replace(/\.svg$/, ".png") +} + // Items we want to add to the JSON-LD schema specifically for blog posts const blogPostSchema = [ { @@ -67,9 +73,7 @@ const blogPostSchema = [ datePublished: entry.data.pubDate, description: entry.data.summary ?? ``, headline: entry.data.title, - image: hasFeatureImage - ? entry.data.featureImage.src - : `/img/og-default.png`, + image: ogImage ?? `/img/og-default.png`, keywords: entry.data.categories.join(","), mainEntityOfPage: `${Astro.site}blog/${entry.id}`, publisher: { @@ -83,7 +87,7 @@ const blogPostSchema = [ entry.data.featureImage?.src) + .filter((src) => src?.startsWith(prefix) && src.endsWith(".svg")) + ) + for (const src of svgs) { + const png = `public${src.replace(/\.svg$/, ".png")}` + if (fs.existsSync(png)) { + throw new Error( + `${png} has the same path as the PNG generated from ${src} for og:image, rename or remove it` + ) + } + } + return [...svgs].map((src) => ({ + params: { path: src.slice(prefix.length, -".svg".length) }, + props: { svgPath: `./public${src}` }, + })) +} + +export async function GET({ props }) { + // SVGs rasterize at 72 DPI, so raise the density to render at full width + // instead of scaling up a smaller bitmap + const { width: svgWidth } = await sharp(props.svgPath).metadata() + const png = await sharp(props.svgPath, { density: (72 * width) / svgWidth }) + .resize({ width }) + .flatten({ background: "#ffffff" }) + .png() + .toBuffer() + + return new Response(png, { headers: { "Content-Type": "image/png" } }) +} From 33689a395dbd4e783bfe2d34930473898d00758e Mon Sep 17 00:00:00 2001 From: Stanislav Zhuk Date: Mon, 26 Oct 2026 16:46:19 +0200 Subject: [PATCH 2/5] fix(seo): point og:image at the Cloudflare preview deployment on preview builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Short Summary (TL;DR) On Cloudflare preview builds, `og:image` now uses the preview deployment's own URL instead of `https://ddev.com`, so share checkers such as opengraph.to can load images that only exist on the branch. ## How This PR Solves The Issue `og:image` was always built from `site` in `astro.config.mjs`, so a preview advertised its images on production, where a new image returns 404 until the branch is merged. `Layout.astro` now uses `CF_PAGES_URL` for `og:image` when Cloudflare Pages builds a branch other than `main`. Canonical URLs and JSON-LD still point at `https://ddev.com`, and fork previews, which are built in GitHub Actions without these variables, keep using `https://ddev.com` as before. ## Manual Testing Instructions - View the source of https://20261026-stasadev-og-png.ddev-com-front-end.pages.dev/blog/release-v1-25-4/ and check that `og:image` points at a `*.ddev-com-front-end.pages.dev` URL that loads, while `` is still `https://ddev.com/blog/release-v1-25-4/`. - Run https://www.opengraph.to/u/20261026-stasadev-og-png.ddev-com-front-end.pages.dev/blog/release-v1-25-4 and check that the image loads. - Check that https://ddev.com/blog/release-v1-25-4/ is unaffected after merge, with `og:image` on `https://ddev.com`. 🤖 Developed with assistance from [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 5.5 --- src/layouts/Layout.astro | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 572b22f5..51865ee2 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -91,9 +91,16 @@ let jsonLd = { ], } +// Cloudflare preview builds point og:image at their own deployment, so share +// checkers can load images that aren't on production yet +const ogImageBase = + process.env.CF_PAGES_URL && process.env.CF_PAGES_BRANCH !== 'main' + ? process.env.CF_PAGES_URL + : Astro.site + const ensureAbsoluteUrl = (url: string) => { if (url.startsWith('/')) { - return `${Astro.site}${url.slice(1)}` + return new URL(url, ogImageBase).toString() } return url; @@ -120,7 +127,7 @@ import "../styles/global.css" title: title !== '' ? title : 'DDEV', url: canonicalUrl, type: `website`, - image: ensureAbsoluteUrl(ogImage ? ogImage : `${Astro.site}img/og-default.png`), + image: ensureAbsoluteUrl(ogImage ? ogImage : `/img/og-default.png`), }, image: { alt: ogImageAlt ? ogImageAlt : `DDEV logo`, From eea59e80848cf93bfb884541c41c5a550311b2eb Mon Sep 17 00:00:00 2001 From: Stanislav Zhuk Date: Mon, 26 Oct 2026 16:54:02 +0200 Subject: [PATCH 3/5] fix(seo): point og:image at the fork preview deployment on fork preview builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Short Summary (TL;DR) Fork previews are built in GitHub Actions, which doesn't set `CF_PAGES_URL`, so their `og:image` still pointed at `https://ddev.com`. The fork build now passes its `pr-` preview URL as `PREVIEW_URL`, and `Layout.astro` uses it for `og:image`. ## Manual Testing Instructions - On the next fork PR, view the source of a page on `https://pr-.ddev-com-fork-previews.pages.dev/` and check that `og:image` points at that host. 🤖 Developed with assistance from [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/cloudflare-preview-forks-build.yml | 2 ++ src/layouts/Layout.astro | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cloudflare-preview-forks-build.yml b/.github/workflows/cloudflare-preview-forks-build.yml index 6b444198..51307c4b 100644 --- a/.github/workflows/cloudflare-preview-forks-build.yml +++ b/.github/workflows/cloudflare-preview-forks-build.yml @@ -84,6 +84,8 @@ jobs: run: npm run build env: PREVIEW_BUILD: "true" + # The pr- alias that cloudflare-preview-forks-deploy.yml deploys to + PREVIEW_URL: https://pr-${{ github.event.pull_request.number }}.${{ vars.CF_PAGES_PROJECT || 'ddev-com-fork-previews' }}.pages.dev - name: Save PR number run: echo "${{ github.event.pull_request.number }}" > dist/pr-number.txt diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 51865ee2..9007c322 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -91,12 +91,13 @@ let jsonLd = { ], } -// Cloudflare preview builds point og:image at their own deployment, so share -// checkers can load images that aren't on production yet +// Preview builds point og:image at their own deployment, so share checkers can +// load images that aren't on production yet. PREVIEW_URL is set for forks. const ogImageBase = - process.env.CF_PAGES_URL && process.env.CF_PAGES_BRANCH !== 'main' + process.env.PREVIEW_URL || + (process.env.CF_PAGES_URL && process.env.CF_PAGES_BRANCH !== 'main' ? process.env.CF_PAGES_URL - : Astro.site + : Astro.site) const ensureAbsoluteUrl = (url: string) => { if (url.startsWith('/')) { From 619c8bab86cd74572bb30de35f0037639ac1348c Mon Sep 17 00:00:00 2001 From: Stanislav Zhuk Date: Mon, 26 Oct 2026 16:56:30 +0200 Subject: [PATCH 4/5] fix(seo): add twitter:card, og:locale, and og:image dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Short Summary (TL;DR) opengraph.to flagged missing `twitter:card` and `og:image` dimensions, so X showed a small thumbnail card and Facebook or LinkedIn could show no image on a link's first share. Every page now sets `twitter:card` to `summary_large_image`, `og:locale` to `en_US`, and `og:image:width` and `og:image:height`. ## How This PR Solves The Issue The dimensions come from the Astro asset metadata that `src/lib/images.ts` already loads for raster images under `public/img`, or, for a PNG rendered from an SVG feature image, from the render itself. That render moved into `src/lib/images.ts` and is cached, so the endpoint and the page reporting its size share one result. 🤖 Developed with assistance from [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 5.5 --- src/layouts/Layout.astro | 13 +++++++- src/lib/images.ts | 51 +++++++++++++++++++++++++++++ src/pages/blog/[id].astro | 2 +- src/pages/img/blog/[...path].png.js | 17 +++------- 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 9007c322..15dcb470 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -19,6 +19,7 @@ import { ORG_PHONE, } from "../const" import themeToggleInitScript from "../scripts/theme-toggle-init.js?raw" +import { ogImageSize } from "../lib/images" export interface Props { title: string @@ -107,6 +108,11 @@ const ensureAbsoluteUrl = (url: string) => { return url; } +const ogImagePath = ogImage ? ogImage : `/img/og-default.png` +// Lets Facebook and LinkedIn show the image on the first share, before they +// have downloaded it +const ogImageDimensions = await ogImageSize(ogImagePath) + import "../styles/global.css" --- @@ -128,13 +134,15 @@ import "../styles/global.css" title: title !== '' ? title : 'DDEV', url: canonicalUrl, type: `website`, - image: ensureAbsoluteUrl(ogImage ? ogImage : `/img/og-default.png`), + image: ensureAbsoluteUrl(ogImagePath), }, image: { alt: ogImageAlt ? ogImageAlt : `DDEV logo`, + ...ogImageDimensions, }, optional: { description: description, + locale: `en_US`, siteName: SITE_TITLE, }, article: pubDate ? { @@ -142,6 +150,9 @@ import "../styles/global.css" modifiedTime: modifiedDate ? modifiedDate.toISOString() : undefined, } : undefined, }} + twitter={{ + card: `summary_large_image`, + }} extend={{ link: link, }} diff --git a/src/lib/images.ts b/src/lib/images.ts index fb170164..c97290db 100644 --- a/src/lib/images.ts +++ b/src/lib/images.ts @@ -1,4 +1,6 @@ import type { ImageMetadata } from "astro" +import fs from "fs" +import sharp from "sharp" /** * Images under `public/img` as Astro assets, so a path from a post's frontmatter @@ -27,3 +29,52 @@ const logos = import.meta.glob<{ default: ImageMetadata }>("/public/logos/*", { export function logoUrl(path: string) { return logos[`/public${path}`]?.default.src ?? path } + +const svgPngs = new Map< + string, + Promise<{ data: Buffer; info: sharp.OutputInfo }> +>() + +/** + * Renders an SVG under `public` to a 1200px wide PNG for `og:image`. Builds + * cache it, since the PNG endpoint and the page reporting its size both ask + * for it, but the dev server doesn't, so an edited SVG shows up at once. + */ +export function svgToPng(path: string) { + if (import.meta.env.DEV) { + return renderSvgToPng(`./public${path}`) + } + if (!svgPngs.has(path)) { + svgPngs.set(path, renderSvgToPng(`./public${path}`)) + } + return svgPngs.get(path)! +} + +async function renderSvgToPng(file: string) { + const width = 1200 + // SVGs rasterize at 72 DPI, so raise the density to render at full width + // instead of scaling up a smaller bitmap + const { width: svgWidth = width } = await sharp(file).metadata() + return sharp(file, { density: (72 * width) / svgWidth }) + .resize({ width }) + .flatten({ background: "#ffffff" }) + .png() + .toBuffer({ resolveWithObject: true }) +} + +/** + * Width and height of an `og:image` path, either a raster image in + * `public/img` or a PNG rendered from the SVG next to it. + */ +export async function ogImageSize(path: string) { + const image = await resolveImage(path) + if (typeof image !== "string") { + return { width: image.width, height: image.height } + } + const svg = path.replace(/\.png$/, ".svg") + if (svg !== path && fs.existsSync(`./public${svg}`)) { + const { info } = await svgToPng(svg) + return { width: info.width, height: info.height } + } + return {} +} diff --git a/src/pages/blog/[id].astro b/src/pages/blog/[id].astro index 7d855b98..4b9ed991 100644 --- a/src/pages/blog/[id].astro +++ b/src/pages/blog/[id].astro @@ -40,7 +40,7 @@ const shouldDisplayFeatureImage = entry.data.featureImage?.hide === false) const authorImage = author?.data.avatarUrl -// Social networks don't render SVG, so use the PNG from img/blog/[...path].png.js +// An SVG feature image gets a PNG copy from img/blog/[...path].png.js let ogImage = entry.data.featureImage?.src ?? null if (ogImage?.startsWith("/img/blog/") && ogImage.endsWith(".svg")) { ogImage = ogImage.replace(/\.svg$/, ".png") diff --git a/src/pages/img/blog/[...path].png.js b/src/pages/img/blog/[...path].png.js index a9f800c6..2c9ea9e3 100644 --- a/src/pages/img/blog/[...path].png.js +++ b/src/pages/img/blog/[...path].png.js @@ -1,10 +1,9 @@ import { getCollection } from "astro:content" import fs from "fs" -import sharp from "sharp" +import { svgToPng } from "../../../lib/images" // Social networks don't render SVG in og:image, so each SVG feature image gets // a PNG copy next to it, used by src/pages/blog/[id].astro -const width = 1200 const prefix = "/img/blog/" export async function getStaticPaths() { @@ -24,19 +23,11 @@ export async function getStaticPaths() { } return [...svgs].map((src) => ({ params: { path: src.slice(prefix.length, -".svg".length) }, - props: { svgPath: `./public${src}` }, + props: { svg: src }, })) } export async function GET({ props }) { - // SVGs rasterize at 72 DPI, so raise the density to render at full width - // instead of scaling up a smaller bitmap - const { width: svgWidth } = await sharp(props.svgPath).metadata() - const png = await sharp(props.svgPath, { density: (72 * width) / svgWidth }) - .resize({ width }) - .flatten({ background: "#ffffff" }) - .png() - .toBuffer() - - return new Response(png, { headers: { "Content-Type": "image/png" } }) + const { data } = await svgToPng(props.svg) + return new Response(data, { headers: { "Content-Type": "image/png" } }) } From 606fe2b1865a1d440e31d5f94eeb71f48c3482a8 Mon Sep 17 00:00:00 2001 From: Stanislav Zhuk Date: Mon, 26 Oct 2026 17:01:49 +0200 Subject: [PATCH 5/5] fix(seo): use an absolute URL for the blog post JSON-LD image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Short Summary (TL;DR) The `BlogPosting` JSON-LD `image` was a relative path such as `/img/og-default.png`, while Google's structured data documentation asks for absolute URLs. It now resolves against `https://ddev.com`, like the canonical URL. 🤖 Developed with assistance from [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 5.5 --- src/pages/blog/[id].astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/blog/[id].astro b/src/pages/blog/[id].astro index 4b9ed991..eb332420 100644 --- a/src/pages/blog/[id].astro +++ b/src/pages/blog/[id].astro @@ -73,7 +73,7 @@ const blogPostSchema = [ datePublished: entry.data.pubDate, description: entry.data.summary ?? ``, headline: entry.data.title, - image: ogImage ?? `/img/og-default.png`, + image: new URL(ogImage ?? `/img/og-default.png`, Astro.site).toString(), keywords: entry.data.categories.join(","), mainEntityOfPage: `${Astro.site}blog/${entry.id}`, publisher: {