perf: image loading, smaller assets, server-rendered code blocks

- Only preload the post cover and latest-post image instead of every image
- Drop in-post image quality from 100 to 90 and add responsive sizes
- Downscale 2940px screenshots to 1800px; convert linkboard-old to WebP
  (3.1 MB -> 60 KB)
- Make CodeWrapper a server component; only CopyButton ships to the client
- Pin the Mermaid CDN script to an exact version for reproducible builds
This commit is contained in:
HangerThem 2026-10-19 14:17:18 +02:00
parent 4301e50ab3
commit 88c8676d3f
13 changed files with 44 additions and 20 deletions

View file

@ -109,7 +109,9 @@ export default async function Post({ params }: { params: Promise<{ slug: string
for SEO.
</span>
)}
{meta.coverImage && <ImageWrapper src={meta.coverImage} alt={meta.coverImageAlt} />}
{meta.coverImage && (
<ImageWrapper src={meta.coverImage} alt={meta.coverImageAlt} priority />
)}
<div className="prose">{content}</div>
{meta.tags && (

View file

@ -1,5 +1,3 @@
'use client'
import { CopyButton } from '@/components/ui/CopyButton'
import { getTextContent } from '@/lib/react-node-text'
@ -13,12 +11,9 @@ export function CodeWrapper({ language, children }: CodeWrapperProps) {
const lineCount = text.length === 0 ? 1 : text.split('\n').length
return (
<div className="bg-foreground/10 rounded-md overflow-x-auto border border-border bg-fg/10">
<div
className="flex items-center gap-2 px-2 h-6 border-b border-border w-full"
aria-hidden="true"
>
<div className="space-x-1">
<div className="rounded-md overflow-x-auto border border-border bg-fg/10">
<div className="flex items-center gap-2 px-2 h-6 border-b border-border w-full">
<div className="space-x-1" aria-hidden="true">
<span className="w-2 h-2 bg-border rounded-full inline-block"></span>
<span className="w-2 h-2 bg-border rounded-full inline-block"></span>
<span className="w-2 h-2 bg-border rounded-full inline-block"></span>
@ -28,7 +23,10 @@ export function CodeWrapper({ language, children }: CodeWrapperProps) {
</div>
<div className="flex">
<div className="select-none text-right py-2 px-3 border-r border-border text-white font-mono text-sm leading-6 min-w-6">
<div
className="select-none text-right py-2 px-3 border-r border-border font-mono text-sm leading-6 min-w-6"
aria-hidden="true"
>
{Array.from({ length: lineCount }, (_, i) => (
<span className="block text-fg-dim" key={i}>
{i + 1}

View file

@ -4,10 +4,19 @@ import Image from 'next/image'
type ImageWrapperProps = {
src: string
alt?: string
/** Visible caption. Falls back to the alt text when omitted. */
caption?: string
isListing?: boolean
/** Preload the image. Only set this for above-the-fold images (covers, latest post). */
priority?: boolean
}
export function ImageWrapper({ src, alt, isListing }: ImageWrapperProps) {
export function ImageWrapper({ src, alt, caption, isListing, priority }: ImageWrapperProps) {
const captionText = caption ?? alt
// When the caption just repeats the alt text, hide it from screen readers so it isn't
// announced twice
const captionIsAlt = caption === undefined
return (
<figure className={cn('flex flex-col items-center gap-1 mb-4 w-full', isListing && 'h-full')}>
<div className="border border-border rounded-xl w-full overflow-hidden flex-1">
@ -27,13 +36,19 @@ export function ImageWrapper({ src, alt, isListing }: ImageWrapperProps) {
alt={alt ?? ''}
width={isListing ? 400 : 900}
height={isListing ? 240 : 600}
quality={isListing ? 75 : 100}
priority={true}
sizes={isListing ? '(min-width: 768px) 400px, 100vw' : '(min-width: 896px) 900px, 100vw'}
quality={isListing ? 75 : 90}
priority={priority}
className="w-full mx-auto object-contain h-full max-h-150"
/>
</div>
{!isListing && alt && (
<figcaption className="block text-xs text-fg-dim font-mono text-center">{alt}</figcaption>
{!isListing && captionText && (
<figcaption
className="block text-xs text-fg-dim font-mono text-center"
aria-hidden={captionIsAlt || undefined}
>
{captionText}
</figcaption>
)}
</figure>
)

View file

@ -29,7 +29,14 @@ export function Post({ post, isLatest, showImageSlot }: PostProps) {
!coverImage && 'hidden md:block',
)}
>
{coverImage && <ImageWrapper src={coverImage} alt={coverImageAlt ?? title} isListing />}
{coverImage && (
<ImageWrapper
src={coverImage}
alt={coverImageAlt ?? title}
isListing
priority={isLatest}
/>
)}
</div>
)}
<div className={cn('flex flex-col gap-1 w-full', isLatest && 'flex-1')}>

View file

@ -54,7 +54,7 @@ The new version has focus on ease of use for not tech-savvy users, with a simple
**Status:** Active / Maintained
**Screenshots:**
![Linkboard UI (Old Version)](linkboard-old.png)
![Linkboard UI (Old Version)](linkboard-old.webp)
![Linkboard UI (New Version)](linkboard-new.png)
![Linkboard Editor](linkboard-editor.png)

View file

@ -43,7 +43,7 @@ export async function renderMermaidToSvg(chart: string): Promise<string> {
<html><body><div id="target"></div></body></html>
`)
await page.addScriptTag({
url: 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js',
url: 'https://cdn.jsdelivr.net/npm/mermaid@11.17.2/dist/mermaid.min.js',
})
const svg = await page.evaluate(async (source) => {

View file

@ -96,7 +96,9 @@ export async function getPostBySlug(slug: string, postDirectory: string = postsD
typeof props['data-language'] === 'string' ? props['data-language'] : undefined
return <CodeWrapper language={language}>{children}</CodeWrapper>
},
img: ({ src, alt }: { src: string; alt: string }) => <ImageWrapper src={src} alt={alt} />,
img: ({ src, alt, title }: { src: string; alt: string; title?: string }) => (
<ImageWrapper src={src} alt={alt} caption={title} />
),
a: ({ href, children, ...props }: { href?: string; children: ReactNode }) => {
if (href?.startsWith('http')) return <ExternalLink href={href}>{children}</ExternalLink>
// Only internal routes go through next/link; anchors, mailto:, footnotes etc. stay plain

View file

@ -2,7 +2,7 @@ import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
qualities: [75, 100],
qualities: [75, 90],
},
env: {
GIT_COMMIT_HASH: process.env.SOURCE_COMMIT,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 KiB

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB