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 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">
|
||||
|
|
|
|||
|
|
@ -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()}`,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()}`,
|
||||
|
|
|
|||
|
|
@ -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, '&')
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
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,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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue