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 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
@ -109,7 +109,9 @@ export default async function Post({ params }: { params: Promise<{ slug: string
|
||||||
for SEO.
|
for SEO.
|
||||||
</span>
|
</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>
|
<div className="prose">{content}</div>
|
||||||
|
|
||||||
{meta.tags && (
|
{meta.tags && (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { CopyButton } from '@/components/ui/CopyButton'
|
import { CopyButton } from '@/components/ui/CopyButton'
|
||||||
import { getTextContent } from '@/lib/react-node-text'
|
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
|
const lineCount = text.length === 0 ? 1 : text.split('\n').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-foreground/10 rounded-md overflow-x-auto border border-border bg-fg/10">
|
<div className="rounded-md overflow-x-auto border border-border bg-fg/10">
|
||||||
<div
|
<div className="flex items-center gap-2 px-2 h-6 border-b border-border w-full">
|
||||||
className="flex items-center gap-2 px-2 h-6 border-b border-border w-full"
|
<div className="space-x-1" aria-hidden="true">
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div className="space-x-1">
|
|
||||||
<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>
|
<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>
|
||||||
|
|
||||||
<div className="flex">
|
<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) => (
|
{Array.from({ length: lineCount }, (_, i) => (
|
||||||
<span className="block text-fg-dim" key={i}>
|
<span className="block text-fg-dim" key={i}>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,19 @@ import Image from 'next/image'
|
||||||
type ImageWrapperProps = {
|
type ImageWrapperProps = {
|
||||||
src: string
|
src: string
|
||||||
alt?: string
|
alt?: string
|
||||||
|
/** Visible caption. Falls back to the alt text when omitted. */
|
||||||
|
caption?: string
|
||||||
isListing?: boolean
|
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 (
|
return (
|
||||||
<figure className={cn('flex flex-col items-center gap-1 mb-4 w-full', isListing && 'h-full')}>
|
<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">
|
<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 ?? ''}
|
alt={alt ?? ''}
|
||||||
width={isListing ? 400 : 900}
|
width={isListing ? 400 : 900}
|
||||||
height={isListing ? 240 : 600}
|
height={isListing ? 240 : 600}
|
||||||
quality={isListing ? 75 : 100}
|
sizes={isListing ? '(min-width: 768px) 400px, 100vw' : '(min-width: 896px) 900px, 100vw'}
|
||||||
priority={true}
|
quality={isListing ? 75 : 90}
|
||||||
|
priority={priority}
|
||||||
className="w-full mx-auto object-contain h-full max-h-150"
|
className="w-full mx-auto object-contain h-full max-h-150"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isListing && alt && (
|
{!isListing && captionText && (
|
||||||
<figcaption className="block text-xs text-fg-dim font-mono text-center">{alt}</figcaption>
|
<figcaption
|
||||||
|
className="block text-xs text-fg-dim font-mono text-center"
|
||||||
|
aria-hidden={captionIsAlt || undefined}
|
||||||
|
>
|
||||||
|
{captionText}
|
||||||
|
</figcaption>
|
||||||
)}
|
)}
|
||||||
</figure>
|
</figure>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,14 @@ export function Post({ post, isLatest, showImageSlot }: PostProps) {
|
||||||
!coverImage && 'hidden md:block',
|
!coverImage && 'hidden md:block',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{coverImage && <ImageWrapper src={coverImage} alt={coverImageAlt ?? title} isListing />}
|
{coverImage && (
|
||||||
|
<ImageWrapper
|
||||||
|
src={coverImage}
|
||||||
|
alt={coverImageAlt ?? title}
|
||||||
|
isListing
|
||||||
|
priority={isLatest}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={cn('flex flex-col gap-1 w-full', isLatest && 'flex-1')}>
|
<div className={cn('flex flex-col gap-1 w-full', isLatest && 'flex-1')}>
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ The new version has focus on ease of use for not tech-savvy users, with a simple
|
||||||
**Status:** Active / Maintained
|
**Status:** Active / Maintained
|
||||||
|
|
||||||
**Screenshots:**
|
**Screenshots:**
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export async function renderMermaidToSvg(chart: string): Promise<string> {
|
||||||
<html><body><div id="target"></div></body></html>
|
<html><body><div id="target"></div></body></html>
|
||||||
`)
|
`)
|
||||||
await page.addScriptTag({
|
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) => {
|
const svg = await page.evaluate(async (source) => {
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,9 @@ export async function getPostBySlug(slug: string, postDirectory: string = postsD
|
||||||
typeof props['data-language'] === 'string' ? props['data-language'] : undefined
|
typeof props['data-language'] === 'string' ? props['data-language'] : undefined
|
||||||
return <CodeWrapper language={language}>{children}</CodeWrapper>
|
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 }) => {
|
a: ({ href, children, ...props }: { href?: string; children: ReactNode }) => {
|
||||||
if (href?.startsWith('http')) return <ExternalLink href={href}>{children}</ExternalLink>
|
if (href?.startsWith('http')) return <ExternalLink href={href}>{children}</ExternalLink>
|
||||||
// Only internal routes go through next/link; anchors, mailto:, footnotes etc. stay plain
|
// Only internal routes go through next/link; anchors, mailto:, footnotes etc. stay plain
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { NextConfig } from 'next'
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
images: {
|
images: {
|
||||||
qualities: [75, 100],
|
qualities: [75, 90],
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
GIT_COMMIT_HASH: process.env.SOURCE_COMMIT,
|
GIT_COMMIT_HASH: process.env.SOURCE_COMMIT,
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 471 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 708 KiB After Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3 MiB |
BIN
public/posts/linkboard-old.webp
Normal file
|
After Width: | Height: | Size: 59 KiB |