diff --git a/README.md b/README.md index 63d10f098..5f6257541 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,17 @@ Made by [@mdo](https://twitter.com/mdo), built on [Astro](https://astro.build), 2. Run `npm start` and open . 3. Run `npm test` to build the site and run all linters. +## Converting post images + +Export a PNG and an `@2x` PNG next to it, then pass the 1x file to: + +```sh +npm run image:webp -- public/assets/img/2026/09/example.png +``` + +The converter checks that the retina image is exactly twice the width and +height, then creates 1x and 2x AVIF images with near-lossless WebP fallbacks. +Add the files to the post front matter with `banner`, `banner_2x`, +`banner_avif`, and `banner_avif_2x`. + <3 diff --git a/package-lock.json b/package-lock.json index a4fc028e2..ab7315eca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "postcss-prefix-custom-properties": "^0.1.0", "rehype-autolink-headings": "^7.1.0", "sass": "^1.103.1", + "sharp": "^0.35.5", "sirv-cli": "^3.0.1", "stylelint": "16.26.1", "stylelint-config-twbs-bootstrap": "^16.1.0", @@ -1130,7 +1131,6 @@ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -9875,7 +9875,6 @@ "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", diff --git a/package.json b/package.json index 84e2de20a..32b18bf19 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "start": "npm run dev", "dev": "astro dev", "build": "astro build && npm run pagefind", + "image:webp": "node scripts/convert-image.mjs", "pagefind": "pagefind --site dist", "preview": "astro preview", "serve-only": "sirv-cli dist --port 4000", @@ -56,6 +57,7 @@ "postcss-prefix-custom-properties": "^0.1.0", "rehype-autolink-headings": "^7.1.0", "sass": "^1.103.1", + "sharp": "^0.35.5", "sirv-cli": "^3.0.1", "stylelint": "16.26.1", "stylelint-config-twbs-bootstrap": "^16.1.0", diff --git a/public/assets/img/2026/09/bootstrap-v6-alpha-social.png b/public/assets/img/2026/09/bootstrap-v6-alpha-social.png deleted file mode 100644 index abb3391a9..000000000 Binary files a/public/assets/img/2026/09/bootstrap-v6-alpha-social.png and /dev/null differ diff --git a/public/assets/img/2026/09/bootstrap-v6-alpha-social@2x.png b/public/assets/img/2026/09/bootstrap-v6-alpha-social@2x.png deleted file mode 100644 index 9e4bdf47b..000000000 Binary files a/public/assets/img/2026/09/bootstrap-v6-alpha-social@2x.png and /dev/null differ diff --git a/public/assets/img/2026/09/bootstrap-v6-alpha1.avif b/public/assets/img/2026/09/bootstrap-v6-alpha1.avif new file mode 100644 index 000000000..d8817895d Binary files /dev/null and b/public/assets/img/2026/09/bootstrap-v6-alpha1.avif differ diff --git a/public/assets/img/2026/09/bootstrap-v6-alpha1.webp b/public/assets/img/2026/09/bootstrap-v6-alpha1.webp new file mode 100644 index 000000000..9b88ce374 Binary files /dev/null and b/public/assets/img/2026/09/bootstrap-v6-alpha1.webp differ diff --git a/public/assets/img/2026/09/bootstrap-v6-alpha1@2x.avif b/public/assets/img/2026/09/bootstrap-v6-alpha1@2x.avif new file mode 100644 index 000000000..efa1d81b6 Binary files /dev/null and b/public/assets/img/2026/09/bootstrap-v6-alpha1@2x.avif differ diff --git a/public/assets/img/2026/09/bootstrap-v6-alpha1@2x.webp b/public/assets/img/2026/09/bootstrap-v6-alpha1@2x.webp new file mode 100644 index 000000000..762d563ca Binary files /dev/null and b/public/assets/img/2026/09/bootstrap-v6-alpha1@2x.webp differ diff --git a/scripts/convert-image.mjs b/scripts/convert-image.mjs new file mode 100644 index 000000000..80e1dc741 --- /dev/null +++ b/scripts/convert-image.mjs @@ -0,0 +1,74 @@ +import { access } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import sharp from 'sharp' + +const [input] = process.argv.slice(2) + +if (!input || path.extname(input).toLowerCase() !== '.png') { + console.error('Usage: npm run image:webp -- path/to/image.png') + process.exit(1) +} + +const parsed = path.parse(input) + +if (parsed.name.endsWith('@2x')) { + console.error('Pass the 1x PNG; its @2x sibling is discovered automatically.') + process.exit(1) +} + +const input2x = path.join(parsed.dir, `${parsed.name}@2x${parsed.ext}`) + +try { + await access(input2x) +} catch { + console.error(`Missing 2x PNG: ${input2x}`) + process.exit(1) +} + +const [metadata1x, metadata2x] = await Promise.all([ + sharp(input).metadata(), + sharp(input2x).metadata() +]) + +if ( + !metadata1x.width || + !metadata1x.height || + metadata2x.width !== metadata1x.width * 2 || + metadata2x.height !== metadata1x.height * 2 +) { + console.error( + `Expected ${input2x} to be exactly ${metadata1x.width * 2}x${metadata1x.height * 2}px.` + ) + process.exit(1) +} + +const formats = [ + { + extension: '.webp', + options: { + effort: 6, + nearLossless: true, + quality: 80 + } + }, + { + extension: '.avif', + options: { + chromaSubsampling: '4:4:4', + effort: 6, + quality: 70 + } + } +] + +for (const source of [input, input2x]) { + for (const { extension, options } of formats) { + const output = source.replace(/\.png$/i, extension) + const info = await sharp(source) + .toFormat(extension.slice(1), options) + .toFile(output) + + console.log(`${output} (${info.width}x${info.height}px, ${info.size} bytes)`) + } +} diff --git a/src/components/PostMedia.astro b/src/components/PostMedia.astro index 0fd48ce1a..39796c46d 100644 --- a/src/components/PostMedia.astro +++ b/src/components/PostMedia.astro @@ -1,6 +1,11 @@ --- interface Props { banner?: string + banner2x?: string + bannerAvif?: string + bannerAvif2x?: string + bannerWidth?: number + bannerHeight?: number video?: string videoStart?: number title: string @@ -9,7 +14,22 @@ interface Props { hero?: boolean } -const { banner, video, videoStart, title, eager = false, hero = false } = Astro.props +const { + banner, + banner2x, + bannerAvif, + bannerAvif2x, + bannerWidth, + bannerHeight, + video, + videoStart, + title, + eager = false, + hero = false +} = Astro.props +const bannerSrcset = banner && banner2x ? `${banner} 1x, ${banner2x} 2x` : undefined +const bannerAvifSrcset = + bannerAvif && bannerAvif2x ? `${bannerAvif} 1x, ${bannerAvif2x} 2x` : undefined const videoSrc = video ? `https://www.youtube-nocookie.com/embed/${video}?rel=0${videoStart ? `&start=${videoStart}` : ''}` @@ -20,15 +40,33 @@ const videoSrc = video banner ? ( hero ? (
- {title} + + {bannerAvifSrcset && } + {title} +
) : ( - {title} + + {bannerAvifSrcset && } + {title} + ) ) : videoSrc ? (
diff --git a/src/content.config.ts b/src/content.config.ts index dd26283b3..74e8843fb 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -16,6 +16,12 @@ const postsSchema = z.object({ video: z.string().optional(), video_start: z.number().optional(), banner: z.string().optional(), + banner_2x: z.string().optional(), + banner_avif: z.string().optional(), + banner_avif_2x: z.string().optional(), + // Intrinsic size of the banner, so the browser reserves its space before it loads. + banner_width: z.number().optional(), + banner_height: z.number().optional(), // Historical URLs for this post; turned into redirects in astro.config.ts. aliases: z.string().array().optional(), extra_js: z diff --git a/src/content/posts/2026/bootstrap-6-alpha.mdx b/src/content/posts/2026/bootstrap-6-alpha.mdx index d1ca8a88a..1b600e41e 100644 --- a/src/content/posts/2026/bootstrap-6-alpha.mdx +++ b/src/content/posts/2026/bootstrap-6-alpha.mdx @@ -7,7 +7,12 @@ major: true keywords: - bootstrap - release -banner: /assets/img/2026/09/bootstrap-v6-alpha-social.png +banner: /assets/img/2026/09/bootstrap-v6-alpha1.webp +banner_2x: /assets/img/2026/09/bootstrap-v6-alpha1@2x.webp +banner_avif: /assets/img/2026/09/bootstrap-v6-alpha1.avif +banner_avif_2x: /assets/img/2026/09/bootstrap-v6-alpha1@2x.avif +banner_width: 1000 +banner_height: 525 --- import Example from '@twbs/bui/components/Example' diff --git a/src/pages/[...slug].astro b/src/pages/[...slug].astro index 8849fcd47..604d30dd4 100644 --- a/src/pages/[...slug].astro +++ b/src/pages/[...slug].astro @@ -102,6 +102,11 @@ const related = scored