fix: tag/category routes, recently updated list, feed and no-js flash

- Slugify tag/category static params so pre-rendered pages match links;
  disable dynamic params and return a real 404 via notFound()
- Drop bogus generateStaticParams from the tags/categories index pages
- Add a styled not-found page
- Sort 'Recently updated' by last modification before slicing
- Atom feed: correct rel=self URL, use updated dates, valid content type
- Swap no-js -> js class in an inline head script before first paint
- Markdown links: only route internal paths through next/link and
  tolerate missing hrefs
This commit is contained in:
HangerThem 2026-10-19 14:15:57 +02:00
parent ccaa7b6871
commit 4301e50ab3
9 changed files with 75 additions and 62 deletions

View file

@ -3,6 +3,8 @@ import { getAllCategories, getCategoryBySlug, getPostsByCategory } from '@/lib/c
import { dateFormatter } from '@/utils/time'
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { slugify } from '@/utils/slug'
type CategoryPageProps = {
params: Promise<{
@ -13,13 +15,16 @@ type CategoryPageProps = {
export async function generateStaticParams() {
const categories = getAllCategories()
return Object.keys(categories).map((category) => ({
categorySlug: category,
categorySlug: slugify(category),
}))
}
export const dynamicParams = false
export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> {
const { categorySlug } = await params
const category = getCategoryBySlug(categorySlug)
if (!category) notFound()
return {
title: `${category} | ${metadata.title!.toString()}`,
@ -39,14 +44,7 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
const posts = getPostsByCategory(categorySlug)
const category = getCategoryBySlug(categorySlug)
if (!category) {
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>
)
}
if (!category) notFound()
return (
<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 { 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> {
return {
title: `Categories | ${metadata.title!.toString()}`,

View file

@ -3,6 +3,8 @@ import { getAllTags, getPostsByTag, getTagBySlug } from '@/lib/tags'
import { dateFormatter } from '@/utils/time'
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { slugify } from '@/utils/slug'
type TagPageProps = {
params: Promise<{
@ -13,13 +15,16 @@ type TagPageProps = {
export async function generateStaticParams() {
const tags = getAllTags()
return Object.keys(tags).map((tag) => ({
tagSlug: tag,
tagSlug: slugify(tag),
}))
}
export const dynamicParams = false
export async function generateMetadata({ params }: TagPageProps): Promise<Metadata> {
const { tagSlug } = await params
const tag = getTagBySlug(tagSlug)
if (!tag) notFound()
return {
title: `${tag} | ${metadata.title!.toString()}`,
@ -39,14 +44,7 @@ export default async function TagPage({ params }: TagPageProps) {
const posts = getPostsByTag(tagSlug)
const tag = getTagBySlug(tagSlug)
if (!tag) {
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>
)
}
if (!tag) notFound()
return (
<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 { 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> {
return {
title: `Tags | ${metadata.title!.toString()}`,

View file

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

View file

@ -1,5 +1,4 @@
import { Space_Grotesk } from 'next/font/google'
import Script from 'next/script'
import { Sidebar } from '@/components/layout/Sidebar'
import { MobileNavbar } from '@/components/layout/MobileNavbar'
import { metadata } from '@/app/metadata'
@ -18,12 +17,19 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html lang="en" className={`${spaceGrotesk.variable} h-full antialiased no-js`}>
<Script
<html
lang="en"
className={`${spaceGrotesk.variable} h-full antialiased no-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">
<Sidebar />
<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,18 +2,17 @@ import { getAllPostsMeta } from '@/lib/posts'
import Link from 'next/link'
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 (
<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">
<h2 className="text-xl font-bold mb-4">Recently updated</h2>
<ul className="flex flex-col gap-2 ml-1">
{posts
.toSorted(
(a, b) =>
new Date(b.updated ?? b.date).getTime() - new Date(a.updated ?? a.date).getTime(),
)
.map((post) => (
{posts.map((post) => (
<li key={post.slug} className="text-sm text-fg-dim">
<Link href={`/posts/${post.slug}`} className="hover:underline">
{post.title}

View file

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