Bug fixes, SEO, performance and accessibility pass #1
9 changed files with 75 additions and 62 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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()}`,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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()}`,
|
||||||
|
|
|
||||||
|
|
@ -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, '&')
|
.replace(/&/g, '&')
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
16
app/not-found.tsx
Normal 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't here. It may have moved, or never existed.
|
||||||
|
</p>
|
||||||
|
<Link href="/" className="text-accent hover:underline">
|
||||||
|
Back to the homepage
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue