Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion .claude/skills/blog-images/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/cloudflare-preview-forks-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ jobs:
run: npm run build
env:
PREVIEW_BUILD: "true"
# The pr-<number> 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
Expand Down
23 changes: 21 additions & 2 deletions src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
---

Expand All @@ -120,20 +134,25 @@ 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 ? {
publishedTime: pubDate.toISOString(),
modifiedTime: modifiedDate ? modifiedDate.toISOString() : undefined,
} : undefined,
}}
twitter={{
card: `summary_large_image`,
}}
extend={{
link: link,
}}
Expand Down
51 changes: 51 additions & 0 deletions src/lib/images.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {}
}
12 changes: 8 additions & 4 deletions src/pages/blog/[id].astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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: {
Expand All @@ -83,7 +87,7 @@ const blogPostSchema = [
<Layout
title={entry.data.title}
description={entry.data.summary ? entry.data.summary : null}
ogImage={hasFeatureImage ? entry.data.featureImage.src : null}
ogImage={ogImage}
ogImageAlt={hasFeatureImage ? entry.data.featureImage.alt : null}
structuredData={blogPostSchema}
pubDate={entry.data.pubDate}
Expand Down
33 changes: 33 additions & 0 deletions src/pages/img/blog/[...path].png.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getCollection } from "astro:content"
import fs from "fs"
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 prefix = "/img/blog/"

export async function getStaticPaths() {
const blogEntries = await getCollection("blog")
const svgs = new Set(
blogEntries
.map((entry) => 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" } })
}