Bug fixes, SEO, performance and accessibility pass #1

Merged
HangerThem merged 6 commits from dev into main 2026-10-19 12:41:44 +00:00
9 changed files with 75 additions and 62 deletions
Showing only changes of commit 4301e50ab3 - Show all commits

View file

@ -3,6 +3,8 @@ import { getAllCategories, getCategoryBySlug, getPostsByCategory } from '@/lib/c
import { dateFormatter } from '@/utils/time' import { dateFormatter } from '@/utils/time'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { notFound } from 'next/navigation'
import { slugify } from '@/utils/slug'
type CategoryPageProps = { type CategoryPageProps = {
params: Promise<{ params: Promise<{
@ -13,13 +15,16 @@ type CategoryPageProps = {
export async function generateStaticParams() { export async function generateStaticParams() {
const categories = getAllCategories() const categories = getAllCategories()
return Object.keys(categories).map((category) => ({ return Object.keys(categories).map((category) => ({
categorySlug: category, categorySlug: slugify(category),
})) }))
} }
export const dynamicParams = false
export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> { export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> {
const { categorySlug } = await params const { categorySlug } = await params
const category = getCategoryBySlug(categorySlug) const category = getCategoryBySlug(categorySlug)
if (!category) notFound()
return { return {
title: `${category} | ${metadata.title!.toString()}`, title: `${category} | ${metadata.title!.toString()}`,
@ -39,14 +44,7 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
const posts = getPostsByCategory(categorySlug) const posts = getPostsByCategory(categorySlug)
const category = getCategoryBySlug(categorySlug) const category = getCategoryBySlug(categorySlug)
if (!category) { if (!category) notFound()
return (
<main className="max-w-3xl 2xl:max-w-4xl mx-auto px-4 py-8 lg:py-16 w-full">
<h1>Category not found</h1>
<Link href="/categories">Back to categories</Link>
</main>
)
}
return ( return (
<main className="max-w-3xl 2xl:max-w-4xl mx-auto px-4 py-8 mb-8 lg:py-16 w-full"> <main className="max-w-3xl 2xl:max-w-4xl mx-auto px-4 py-8 mb-8 lg:py-16 w-full">

View file

@ -6,13 +6,6 @@ import Link from 'next/link'
import { metadata } from '@/app/metadata' import { metadata } from '@/app/metadata'
import { dateFormatter } from '@/utils/time' import { dateFormatter } from '@/utils/time'
export async function generateStaticParams() {
const categories = getAllCategories()
return Object.keys(categories).map((category) => ({
categorySlug: category,
}))
}
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return {
title: `Categories | ${metadata.title!.toString()}`, title: `Categories | ${metadata.title!.toString()}`,

View file

@ -3,6 +3,8 @@ import { getAllTags, getPostsByTag, getTagBySlug } from '@/lib/tags'
import { dateFormatter } from '@/utils/time' import { dateFormatter } from '@/utils/time'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { notFound } from 'next/navigation'
import { slugify } from '@/utils/slug'
type TagPageProps = { type TagPageProps = {
params: Promise<{ params: Promise<{
@ -13,13 +15,16 @@ type TagPageProps = {
export async function generateStaticParams() { export async function generateStaticParams() {
const tags = getAllTags() const tags = getAllTags()
return Object.keys(tags).map((tag) => ({ return Object.keys(tags).map((tag) => ({
tagSlug: tag, tagSlug: slugify(tag),
})) }))
} }
export const dynamicParams = false
export async function generateMetadata({ params }: TagPageProps): Promise<Metadata> { export async function generateMetadata({ params }: TagPageProps): Promise<Metadata> {
const { tagSlug } = await params const { tagSlug } = await params
const tag = getTagBySlug(tagSlug) const tag = getTagBySlug(tagSlug)
if (!tag) notFound()
return { return {
title: `${tag} | ${metadata.title!.toString()}`, title: `${tag} | ${metadata.title!.toString()}`,
@ -39,14 +44,7 @@ export default async function TagPage({ params }: TagPageProps) {
const posts = getPostsByTag(tagSlug) const posts = getPostsByTag(tagSlug)
const tag = getTagBySlug(tagSlug) const tag = getTagBySlug(tagSlug)
if (!tag) { if (!tag) notFound()
return (
<main className="max-w-3xl 2xl:max-w-4xl mx-auto px-4 py-8 lg:py-16 w-full">
<h1>Tag not found</h1>
<Link href="/tags">Back to tags</Link>
</main>
)
}
return ( return (
<main className="max-w-3xl 2xl:max-w-4xl mx-auto px-4 py-8 mb-8 lg:py-16 w-full"> <main className="max-w-3xl 2xl:max-w-4xl mx-auto px-4 py-8 mb-8 lg:py-16 w-full">

View file

@ -6,13 +6,6 @@ import Link from 'next/link'
import { metadata } from '@/app/metadata' import { metadata } from '@/app/metadata'
import { dateFormatter } from '@/utils/time' import { dateFormatter } from '@/utils/time'
export async function generateStaticParams() {
const tags = getAllTags()
return Object.keys(tags).map((tag) => ({
tagSlug: tag,
}))
}
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return {
title: `Tags | ${metadata.title!.toString()}`, title: `Tags | ${metadata.title!.toString()}`,

View file

@ -1,10 +1,13 @@
import { metadata } from '@/app/metadata' import { metadata } from '@/app/metadata'
import { getAllPostsMeta } from '@/lib/posts' import { getAllPostsMeta } from '@/lib/posts'
import type { PostMeta } from '@/types/Post.type'
export const dynamic = 'force-static' export const dynamic = 'force-static'
const SITE_URL = process.env.SITE_URL const SITE_URL = process.env.SITE_URL
const lastModified = (p: PostMeta) => new Date(p.updated ?? p.date)
function escapeXml(str: string) { function escapeXml(str: string) {
return str return str
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@ -18,20 +21,23 @@ export async function GET(req: Request) {
const posts = getAllPostsMeta() const posts = getAllPostsMeta()
const updated = const updated =
posts.length > 0 ? new Date(posts[0].date).toISOString() : new Date().toISOString() posts.length > 0
? new Date(Math.max(...posts.map((p) => lastModified(p).getTime()))).toISOString()
: new Date().toISOString()
const entries = posts const entries = posts
.map((p) => { .map((p) => {
const url = `${SITE_URL}/posts/${p.slug}` const url = `${SITE_URL}/posts/${p.slug}`
const date = new Date(p.date).toISOString() const date = new Date(p.date).toISOString()
const modified = lastModified(p).toISOString()
return ` return `
<entry> <entry>
<title>${escapeXml(p.title)}</title> <title>${escapeXml(p.title)}</title>
<link href="${url}" rel="alternate" type="text/html" /> <link href="${url}" rel="alternate" type="text/html" />
<id>${url}</id> <id>${url}</id>
<published>${date}</published> <published>${date}</published>
<updated>${date}</updated> <updated>${modified}</updated>
<content type="html" src="${url}" /> <content type="text/html" src="${url}" />
<summary>${escapeXml(p.description)}</summary> <summary>${escapeXml(p.description)}</summary>
</entry>` </entry>`
}) })
@ -45,7 +51,7 @@ export async function GET(req: Request) {
<author> <author>
<name>${escapeXml(metadata.creator!.toString())}</name> <name>${escapeXml(metadata.creator!.toString())}</name>
</author> </author>
<link href="${SITE_URL}/feed" rel="self" type="application/atom+xml" /> <link href="${SITE_URL}/feed.xml" rel="self" type="application/atom+xml" />
<link href="${SITE_URL}" rel="alternate" type="text/html" /> <link href="${SITE_URL}" rel="alternate" type="text/html" />
<rights> © ${new Date().getFullYear()} Frank Borisjuk </rights> <rights> © ${new Date().getFullYear()} Frank Borisjuk </rights>
${entries} ${entries}

View file

@ -1,5 +1,4 @@
import { Space_Grotesk } from 'next/font/google' import { Space_Grotesk } from 'next/font/google'
import Script from 'next/script'
import { Sidebar } from '@/components/layout/Sidebar' import { Sidebar } from '@/components/layout/Sidebar'
import { MobileNavbar } from '@/components/layout/MobileNavbar' import { MobileNavbar } from '@/components/layout/MobileNavbar'
import { metadata } from '@/app/metadata' import { metadata } from '@/app/metadata'
@ -18,12 +17,19 @@ export default function RootLayout({
children: React.ReactNode children: React.ReactNode
}>) { }>) {
return ( return (
<html lang="en" className={`${spaceGrotesk.variable} h-full antialiased no-js`}> <html
<Script lang="en"
dangerouslySetInnerHTML={{ className={`${spaceGrotesk.variable} h-full antialiased no-js`}
__html: `document.documentElement.classList.replace('no-js','js')`, suppressHydrationWarning
}} >
/> <head>
{/* Runs before first paint so `js-only` elements don't flash in after hydration */}
<script
dangerouslySetInnerHTML={{
__html: `document.documentElement.classList.replace('no-js','js')`,
}}
/>
</head>
<body className="min-h-full flex"> <body className="min-h-full flex">
<Sidebar /> <Sidebar />
<MobileNavbar /> <MobileNavbar />

16
app/not-found.tsx Normal file
View file

@ -0,0 +1,16 @@
import Link from 'next/link'
export default function NotFound() {
return (
<main className="max-w-3xl 2xl:max-w-4xl mx-auto px-4 py-8 mb-8 lg:py-16 w-full">
<div className="font-mono text-sm text-fg-dim mb-2">~/404</div>
<h1 className="text-4xl font-bold mb-4">Page not found</h1>
<p className="text-fg-dim mb-6">
Whatever you were looking for isn&apos;t here. It may have moved, or never existed.
</p>
<Link href="/" className="text-accent hover:underline">
Back to the homepage
</Link>
</main>
)
}

View file

@ -2,24 +2,23 @@ import { getAllPostsMeta } from '@/lib/posts'
import Link from 'next/link' import Link from 'next/link'
export function RecentlyUpdatedPosts() { export function RecentlyUpdatedPosts() {
const posts = getAllPostsMeta().slice(0, 5) const posts = getAllPostsMeta()
.toSorted(
(a, b) => new Date(b.updated ?? b.date).getTime() - new Date(a.updated ?? a.date).getTime(),
)
.slice(0, 5)
return ( return (
<aside className="w-80 2xl:w-100 hidden lg:block"> <aside className="w-80 2xl:w-100 hidden lg:block">
<div className="sticky top-12 border-l border-border h-fit pl-4 py-4"> <div className="sticky top-12 border-l border-border h-fit pl-4 py-4">
<h2 className="text-xl font-bold mb-4">Recently updated</h2> <h2 className="text-xl font-bold mb-4">Recently updated</h2>
<ul className="flex flex-col gap-2 ml-1"> <ul className="flex flex-col gap-2 ml-1">
{posts {posts.map((post) => (
.toSorted( <li key={post.slug} className="text-sm text-fg-dim">
(a, b) => <Link href={`/posts/${post.slug}`} className="hover:underline">
new Date(b.updated ?? b.date).getTime() - new Date(a.updated ?? a.date).getTime(), {post.title}
) </Link>
.map((post) => ( </li>
<li key={post.slug} className="text-sm text-fg-dim"> ))}
<Link href={`/posts/${post.slug}`} className="hover:underline">
{post.title}
</Link>
</li>
))}
</ul> </ul>
</div> </div>
</aside> </aside>

View file

@ -97,12 +97,16 @@ export async function getPostBySlug(slug: string, postDirectory: string = postsD
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 }: { src: string; alt: string }) => <ImageWrapper src={src} alt={alt} />,
a: ({ href, children }: { href: string; children: ReactNode }) => a: ({ href, children, ...props }: { href?: string; children: ReactNode }) => {
href?.startsWith('http') ? ( if (href?.startsWith('http')) return <ExternalLink href={href}>{children}</ExternalLink>
<ExternalLink href={href}>{children}</ExternalLink> // Only internal routes go through next/link; anchors, mailto:, footnotes etc. stay plain
) : ( if (href?.startsWith('/')) return <Link href={href}>{children}</Link>
<Link href={href}>{children}</Link> return (
), <a href={href} {...props}>
{children}
</a>
)
},
blockquote: ({ children }: { children: ReactNode }) => { blockquote: ({ children }: { children: ReactNode }) => {
const textContent = getTextContent(children).trimStart() const textContent = getTextContent(children).trimStart()
const type = textContent.match(/^\[!(\w+)\]/)?.[1]?.toLowerCase() as NoteType | undefined const type = textContent.match(/^\[!(\w+)\]/)?.[1]?.toLowerCase() as NoteType | undefined