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/.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 572b22f5..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 @@ -91,14 +92,27 @@ let jsonLd = { ], } +// 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.PREVIEW_URL || + (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; } +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" --- @@ -120,13 +134,15 @@ import "../styles/global.css" title: title !== '' ? title : 'DDEV', url: canonicalUrl, type: `website`, - image: ensureAbsoluteUrl(ogImage ? ogImage : `${Astro.site}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 ? { @@ -134,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 84f9c81a..eb332420 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 +// 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") +} + // 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: new URL(ogImage ?? `/img/og-default.png`, Astro.site).toString(), 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: { svg: src }, + })) +} + +export async function GET({ props }) { + const { data } = await svgToPng(props.svg) + return new Response(data, { headers: { "Content-Type": "image/png" } }) +}