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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,17 @@ Made by [@mdo](https://twitter.com/mdo), built on [Astro](https://astro.build),
2. Run `npm start` and open <http://localhost:4000>.
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
3 changes: 1 addition & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 74 additions & 0 deletions scripts/convert-image.mjs
Original file line number Diff line number Diff line change
@@ -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)`)
}
}
54 changes: 46 additions & 8 deletions src/components/PostMedia.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
---
interface Props {
banner?: string
banner2x?: string
bannerAvif?: string
bannerAvif2x?: string
bannerWidth?: number
bannerHeight?: number
video?: string
videoStart?: number
title: string
Expand All @@ -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}` : ''}`
Expand All @@ -20,15 +40,33 @@ const videoSrc = video
banner ? (
hero ? (
<div class="post-media w-100 overflow-hidden rounded-8">
<img src={banner} alt={title} loading={eager ? 'eager' : 'lazy'} />
<picture>
{bannerAvifSrcset && <source srcset={bannerAvifSrcset} type="image/avif" />}
<img
src={banner}
srcset={bannerSrcset}
alt={title}
width={bannerWidth}
height={bannerHeight}
loading={eager ? 'eager' : 'lazy'}
fetchpriority={eager ? 'high' : undefined}
/>
</picture>
</div>
) : (
<img
src={banner}
class="d-block img-fluid rounded-3 mb-4"
alt={title}
loading={eager ? 'eager' : 'lazy'}
/>
<picture>
{bannerAvifSrcset && <source srcset={bannerAvifSrcset} type="image/avif" />}
<img
src={banner}
srcset={bannerSrcset}
class="d-block img-fluid rounded-3 mb-4"
alt={title}
width={bannerWidth}
height={bannerHeight}
loading={eager ? 'eager' : 'lazy'}
fetchpriority={eager ? 'high' : undefined}
/>
</picture>
)
) : videoSrc ? (
<div class:list={['ratio', hero && 'post-media w-100 overflow-hidden rounded-8']}>
Expand Down
6 changes: 6 additions & 0 deletions src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/content/posts/2026/bootstrap-6-alpha.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 5 additions & 0 deletions src/pages/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ const related = scored

<PostMedia
banner={post.data.banner}
banner2x={post.data.banner_2x}
bannerAvif={post.data.banner_avif}
bannerAvif2x={post.data.banner_avif_2x}
bannerWidth={post.data.banner_width}
bannerHeight={post.data.banner_height}
video={post.data.video}
videoStart={post.data.video_start}
title={post.data.title}
Expand Down
Loading