Compare commits

...

25 Commits

Author SHA1 Message Date
kbot
975c1bec9b took out boiler 2026-06-03 22:37:41 -05:00
kbot
6a35cc25d4 ready to deploy 2026-06-03 22:31:41 -05:00
kbot
75fea5e49c refactor: remove redundant scroll-m-20 from MDX heading components 2026-06-03 22:04:29 -05:00
kbot
48ad4512c3 feat: add data-scroll-behavior and CSS smooth scroll with reduced-motion fallback 2026-06-03 21:56:05 -05:00
kbot
3aa101812e refactor: replace pixel-based TOC sidebar width and sticky offset with flexible values 2026-06-03 21:55:55 -05:00
kbot
f8383fb471 content: consolidate starter posts into single comprehensive MDX showcase 2026-06-03 21:53:07 -05:00
kbot
0f516bd23b feat: add smooth scroll animation to TOC navigation with reduced-motion support 2026-06-03 21:52:53 -05:00
kbot
64d9eb2474 refactor: replace hardcoded scroll offset pixels with CSS custom property 2026-06-03 21:52:53 -05:00
kbot
29c89cf891 fix: align header max-width to match content container (max-w-6xl) 2026-06-03 21:50:57 -05:00
kbot
7c653f3852 feat: add Callout component with note/tip/warning/danger variants 2026-06-03 21:48:39 -05:00
kbot
c617ee4438 feat: add remark-directive and callout directive transformer 2026-06-03 21:46:08 -05:00
OpenCode Worker
72443140f9 style: widen article reading layout 2026-06-03 12:55:18 -05:00
OpenCode Worker
b28a542a57 copy: rename site branding to Krishna 2026-06-03 12:51:00 -05:00
OpenCode Worker
05440d2d5e copy: simplify home and posts headings 2026-06-03 12:43:07 -05:00
opencode
e8ccf144d6 fix: sync transformer bun lock 2026-06-03 11:41:27 -05:00
opencode
f2b88f1130 style: refine mdx prose math and images 2026-06-03 11:30:13 -05:00
OpenCode Worker
d223c5f512 fix: improve mdx code block pipeline 2026-06-03 11:25:25 -05:00
OpenCode Worker
2a9a3f6550 fix: clean page transitions and navigation a11y 2026-06-03 11:09:57 -05:00
OpenCode Worker
72f2e88673 style: polish blog landing and cards 2026-06-03 11:06:03 -05:00
opencode
755a65d9a6 feat: add fuzzy post search 2026-06-03 11:01:56 -05:00
OpenCode Worker
a897894791 feat: link post tags to static tag pages 2026-06-03 10:58:15 -05:00
OpenCode Worker
381e6225e1 docs: add starter mdx posts 2026-06-03 10:52:55 -05:00
OpenCode Worker
de13ba43b0 fix: keep dynamic blog routes static 2026-06-03 10:46:59 -05:00
kbot
c510abe875 feat: add static tag pages 2026-06-03 10:43:55 -05:00
Krishna Ayyalasomayajula
21be810a68 fix: normalize post metadata and tag helpers 2026-06-03 10:38:19 -05:00
29 changed files with 9914 additions and 207 deletions

1
.gitignore vendored
View File

@@ -40,3 +40,4 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.opencode/** .opencode/**
deploy.zsh

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

View File

@@ -3,6 +3,10 @@
/* Dark mode: class-based (for @wrksz/themes) */ /* Dark mode: class-based (for @wrksz/themes) */
@custom-variant dark (&:is(.dark)); @custom-variant dark (&:is(.dark));
:root {
--scroll-offset: 7.5rem; /* ~120px, generous offset for sticky header */
}
/* === Design Tokens === */ /* === Design Tokens === */
@theme { @theme {
/* Colors — Light mode defaults */ /* Colors — Light mode defaults */
@@ -48,6 +52,7 @@
/* Shadows */ /* Shadows */
--shadow-card: 0 1px 3px oklch(0 0 0 / 0.08); --shadow-card: 0 1px 3px oklch(0 0 0 / 0.08);
--shadow-card-hover: 0 20px 45px -32px oklch(0 0 0 / 0.35), 0 1px 3px oklch(0 0 0 / 0.08);
/* Animations */ /* Animations */
--animate-fade-in: fade-in 0.3s ease-out; --animate-fade-in: fade-in 0.3s ease-out;
@@ -70,10 +75,10 @@
@layer base { @layer base {
html { html {
scroll-padding-top: 96px; scroll-behavior: smooth;
scroll-padding-top: var(--scroll-offset);
} }
body { body {
--header-height: 56px;
font-family: var(--font-serif); font-family: var(--font-serif);
font-weight: 400; font-weight: 400;
line-height: 1.6; line-height: 1.6;
@@ -100,6 +105,11 @@
a:hover { a:hover {
text-decoration-thickness: 3px; text-decoration-thickness: 3px;
} }
:where(a, button, input, textarea, select, [role="button"]):focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
border-radius: 0.375rem;
}
} }
/* === Dark mode token overrides === */ /* === Dark mode token overrides === */
@@ -125,6 +135,198 @@
--color-code-copy-hover: oklch(0.85 0 0); --color-code-copy-hover: oklch(0.85 0 0);
--shadow-card: 0 1px 4px oklch(1 0 0 / 0.06); --shadow-card: 0 1px 4px oklch(1 0 0 / 0.06);
--shadow-card-hover: 0 22px 50px -34px oklch(1 0 0 / 0.22), 0 1px 4px oklch(1 0 0 / 0.08);
}
/* === Editorial list surfaces === */
.editorial-hero {
position: relative;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(circle at 20% 10%, color-mix(in oklch, var(--color-accent) 10%, transparent), transparent 34%),
linear-gradient(135deg, color-mix(in oklch, var(--color-surface) 88%, var(--color-canvas)), var(--color-canvas));
}
.editorial-hero::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.35;
background-image: radial-gradient(color-mix(in oklch, var(--color-ink) 18%, transparent) 0.5px, transparent 0.5px);
background-size: 12px 12px;
mask-image: linear-gradient(135deg, black, transparent 78%);
}
.dark .editorial-hero {
background:
radial-gradient(circle at 20% 10%, color-mix(in oklch, var(--color-accent) 14%, transparent), transparent 36%),
linear-gradient(135deg, color-mix(in oklch, var(--color-surface) 82%, var(--color-canvas)), var(--color-canvas));
}
.dark .editorial-hero::after {
opacity: 0.22;
}
.empty-state {
display: grid;
gap: 0.75rem;
border: 1px dashed color-mix(in oklch, var(--color-border) 78%, var(--color-ink) 22%);
border-radius: 1.25rem;
background: color-mix(in oklch, var(--color-surface) 55%, transparent);
padding: 2rem;
}
/* === Local prose styles (Tailwind Typography plugin is not installed) === */
.prose {
color: var(--color-ink);
font-size: 1rem;
line-height: 1.75;
overflow-wrap: break-word;
}
.prose :where(h1, h2, h3, h4, h5, h6) {
color: var(--color-ink);
scroll-margin-top: var(--scroll-offset);
}
.prose :where(h1) { margin: 2.75rem 0 1rem; }
.prose :where(h2) { margin: 3rem 0 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--color-border); }
.prose :where(h3) { margin: 2.25rem 0 0.75rem; }
.prose :where(h4, h5, h6) { margin: 1.75rem 0 0.5rem; }
.prose :where(h1, h2, h3, h4, h5, h6):first-child { margin-top: 0; }
.prose :where(p) {
margin: 1.25rem 0;
color: var(--color-ink);
}
.article-prose :where(p, ul, ol, blockquote, details, .callout, [data-callout]) {
max-width: 72ch;
}
.prose :where(a) {
color: var(--color-accent);
text-decoration-thickness: 1px;
}
.prose :where(ul, ol) {
margin: 1.5rem 0;
padding-left: 1.5rem;
}
.prose :where(li) { margin: 0.45rem 0; padding-left: 0.2rem; }
.prose :where(li)::marker { color: var(--color-accent); }
.prose :where(li > p) { margin: 0.5rem 0; }
.prose :where(blockquote) {
margin: 2rem 0;
border-left: 4px solid color-mix(in oklch, var(--color-accent) 45%, var(--color-border));
border-radius: 0 1rem 1rem 0;
background: color-mix(in oklch, var(--color-surface) 70%, transparent);
padding: 0.75rem 1rem 0.75rem 1.25rem;
color: var(--color-ink-soft);
}
.prose :where(blockquote p) { margin: 0.5rem 0; }
.prose :where(.callout, [data-callout]) {
margin: 2rem 0;
border: 1px solid color-mix(in oklch, var(--color-accent) 28%, var(--color-border));
border-radius: 1rem;
background: color-mix(in oklch, var(--color-accent) 8%, var(--color-surface));
padding: 1rem;
}
.prose :where(details) {
margin: 2rem 0;
border: 1px solid var(--color-border);
border-radius: 1rem;
background: color-mix(in oklch, var(--color-surface) 82%, var(--color-canvas));
padding: 1rem 1.125rem;
}
.prose :where(summary) {
cursor: pointer;
color: var(--color-ink);
font-weight: 700;
}
.prose :where(details[open] summary) { margin-bottom: 0.75rem; }
.prose :where(details > :last-child) { margin-bottom: 0; }
.prose :where(figure) { margin: 2.5rem 0; }
.prose :where(img) {
display: block;
max-width: min(100%, 56rem);
height: auto;
margin: 2rem auto;
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-surface);
box-shadow: var(--shadow-card);
}
.prose :where(p:has(> img:only-child)) {
margin: 2.5rem 0 1rem;
}
.article-prose :where(p:has(> img:only-child)) {
max-width: none;
}
.prose :where(p:has(> img:only-child) + em),
.prose :where(p:has(> img:only-child) + strong) {
display: block;
margin-top: -0.5rem;
text-align: center;
color: var(--color-ink-soft);
font-size: 0.875rem;
line-height: 1.5;
}
.prose :where(figcaption) {
margin-top: 0.75rem;
color: var(--color-ink-soft);
font-size: 0.875rem;
line-height: 1.5;
text-align: center;
}
.prose :where(table) {
width: 100%;
min-width: 40rem;
border-collapse: collapse;
color: var(--color-ink);
}
.prose :where(th, td) {
border: 1px solid var(--color-border);
padding: 0.75rem 0.875rem;
text-align: left;
vertical-align: top;
}
.prose :where(th) {
background: var(--color-surface);
color: var(--color-ink);
font-weight: 700;
}
.prose :where(tbody tr:nth-child(even)) {
background: color-mix(in oklch, var(--color-surface) 55%, transparent);
}
.prose :where(pre) {
max-width: 100%;
overflow-x: auto;
}
.prose :where(:not(pre) > code) {
color: var(--color-ink);
} }
/* === Code Blocks: Borderless, VS Code Style, Line Numbers === */ /* === Code Blocks: Borderless, VS Code Style, Line Numbers === */
@@ -223,15 +425,19 @@
background-color: oklch(0.10 0 0 / 0.85); background-color: oklch(0.10 0 0 / 0.85);
color: var(--color-code-copy); color: var(--color-code-copy);
} }
[data-rehype-pretty-code-figure]:hover .rehype-pretty-copy { [data-rehype-pretty-code-figure]:hover .rehype-pretty-copy,
[data-rehype-pretty-code-figure] .rehype-pretty-copy:focus-visible,
[data-rehype-pretty-code-figure] .rehype-pretty-copy:focus {
opacity: 1; opacity: 1;
} }
[data-rehype-pretty-code-figure] .rehype-pretty-copy:hover { [data-rehype-pretty-code-figure] .rehype-pretty-copy:hover,
[data-rehype-pretty-code-figure] .rehype-pretty-copy:focus-visible {
@apply border-current; @apply border-current;
background-color: oklch(0.95 0 0 / 0.95); background-color: oklch(0.95 0 0 / 0.95);
color: var(--color-code-copy-hover); color: var(--color-code-copy-hover);
} }
.dark [data-rehype-pretty-code-figure] .rehype-pretty-copy:hover { .dark [data-rehype-pretty-code-figure] .rehype-pretty-copy:hover,
.dark [data-rehype-pretty-code-figure] .rehype-pretty-copy:focus-visible {
background-color: oklch(0.15 0 0 / 0.95); background-color: oklch(0.15 0 0 / 0.95);
} }
[data-rehype-pretty-code-figure] .rehype-pretty-copy.rehype-pretty-copied { [data-rehype-pretty-code-figure] .rehype-pretty-copy.rehype-pretty-copied {
@@ -273,23 +479,24 @@ html.dark code[data-theme*=" "] span {
font-size: 1.1em !important; font-size: 1.1em !important;
} }
.katex-display { .katex-display {
overflow: visible; overflow-x: auto;
overflow-y: hidden;
max-width: 100%; max-width: 100%;
margin: 1.5em 0; margin: 1.75em 0;
padding: 0.25rem 0 0.6rem;
text-align: center; text-align: center;
-webkit-overflow-scrolling: touch;
} }
.katex-display > .katex { .katex-display > .katex {
display: block; display: inline-block;
min-width: max-content;
text-align: center; text-align: center;
white-space: normal; white-space: nowrap;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.katex-display > .katex {
transform: scale(0.85);
transform-origin: center top;
}
.katex-display { .katex-display {
margin: 0.8em 0; margin: 1.25em 0;
text-align: left;
} }
} }
.prose p + .katex-display { margin-top: 0.5em; } .prose p + .katex-display { margin-top: 0.5em; }
@@ -429,3 +636,46 @@ body, body *, [class*="bg-"], [class*="border-"], [class*="text-"] {
outline: 2px solid var(--color-accent); outline: 2px solid var(--color-accent);
outline-offset: 2px; outline-offset: 2px;
} }
/* Callout/Admonition styles */
.callout {
@apply my-6 rounded-lg border p-4 text-sm leading-relaxed;
}
.callout-title {
@apply flex items-center gap-2 font-semibold mb-2;
}
.callout-icon {
@apply text-base;
}
.callout-content {
@apply pl-6;
}
/* Type variants */
.callout-note {
@apply border-accent/30 bg-accent/[0.06];
}
.callout-note .callout-title {
@apply text-accent;
}
.callout-tip {
@apply border-emerald-500/30 bg-emerald-500/[0.06];
}
.callout-tip .callout-title {
@apply text-emerald-600 dark:text-emerald-400;
}
.callout-warning {
@apply border-amber-500/30 bg-amber-500/[0.06];
}
.callout-warning .callout-title {
@apply text-amber-600 dark:text-amber-400;
}
.callout-danger {
@apply border-red-500/30 bg-red-500/[0.06];
}
.callout-danger .callout-title {
@apply text-red-600 dark:text-red-400;
}

View File

@@ -32,13 +32,13 @@ const plexMono = IBM_Plex_Mono({
}) })
export const metadata: Metadata = { export const metadata: Metadata = {
title: { template: '%s | blog', default: 'blog' }, title: { template: '%s | Krishna', default: 'Krishna' },
description: 'A sleek static blog with code and math.', description: 'A sleek static journal with code and math.',
} }
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className={`${fraunces.variable} ${jetbrainsMono.variable} ${plexMono.variable}`} suppressHydrationWarning> <html lang="en" className={`${fraunces.variable} ${jetbrainsMono.variable} ${plexMono.variable}`} data-scroll-behavior="smooth" suppressHydrationWarning>
<body className={`antialiased bg-canvas text-ink ${fraunces.className}`}> <body className={`antialiased bg-canvas text-ink ${fraunces.className}`}>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"

View File

@@ -1,22 +1,30 @@
import { getPosts } from '@/lib/posts' import { getPosts } from '@/lib/posts'
import { PostList } from '@/components/blog/PostList' import { PostList } from '@/components/posts/PostList'
import Template from './template'
export default async function HomePage() { export default async function HomePage() {
const posts = await getPosts() const posts = await getPosts()
return ( return (
<Template> <main className="mx-auto max-w-5xl px-6 py-10 sm:py-14">
<main className="max-w-4xl mx-auto px-6 py-12"> <section aria-labelledby="recent-heading" className="space-y-7">
<header className="mb-16 text-center"> <div className="border-b border-border pb-5">
</header> <h1 id="recent-heading" className="heading-xl m-0 text-ink">
<section> Recent
<h1 className="heading-xl text-ink mb-8">
Latest
</h1> </h1>
</div>
{posts.length > 0 ? (
<PostList posts={posts} /> <PostList posts={posts} />
) : (
<div className="empty-state">
<p className="font-mono text-xs uppercase tracking-[0.22em] text-ink-soft">No posts yet</p>
<h3 className="heading-sm m-0 text-ink">The notebook is ready.</h3>
<p className="m-0 max-w-xl text-sm leading-6 text-ink-soft">
Add your first MDX post and it will show up here with the same polished card treatment.
</p>
</div>
)}
</section> </section>
</main> </main>
</Template>
) )
} }

View File

@@ -1,14 +1,15 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getPosts, getPost } from '@/lib/posts' import Link from 'next/link'
import { TableOfContents } from '@/components/blog/TableOfContents' import { getPosts, getPost, tagToSlug } from '@/lib/posts'
import { TableOfContents } from '@/components/posts/TableOfContents'
import { ScrollToTop } from '@/components/ui/ScrollToTop' import { ScrollToTop } from '@/components/ui/ScrollToTop'
import { ReadingProgress } from '@/components/ui/ReadingProgress' import { ReadingProgress } from '@/components/ui/ReadingProgress'
export const dynamicParams = false
export const dynamic = 'force-static'
export async function generateStaticParams() { export async function generateStaticParams() {
const posts = await getPosts() const posts = await getPosts()
if (posts.length === 0) {
return [{ slug: '__placeholder__' }]
}
return posts.map((post) => ({ slug: post.slug })) return posts.map((post) => ({ slug: post.slug }))
} }
@@ -16,7 +17,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
const slug = (await params).slug const slug = (await params).slug
const post = await getPost(slug) const post = await getPost(slug)
if (!post) return { title: 'Not Found' } if (!post) return { title: 'Not Found' }
return { title: `${post.title} | blog` } return { title: post.title }
} }
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) { export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
@@ -34,11 +35,11 @@ export default async function PostPage({ params }: { params: Promise<{ slug: str
<> <>
<ScrollToTop /> <ScrollToTop />
<ReadingProgress /> <ReadingProgress />
<div className="max-w-4xl mx-auto px-6 py-16"> <div className="mx-auto max-w-6xl px-6 py-16">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_200px] gap-8"> <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_14rem] lg:gap-12">
<article> <article className="min-w-0">
<header className="mb-12"> <header className="mb-12">
<time className="font-mono text-sm text-ink-soft">{new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</time> <time className="font-mono text-sm text-ink-soft" dateTime={post.date}>{new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</time>
<h1 className="heading-xl text-ink mt-3 mb-2"> <h1 className="heading-xl text-ink mt-3 mb-2">
{post.title} {post.title}
</h1> </h1>
@@ -49,22 +50,24 @@ export default async function PostPage({ params }: { params: Promise<{ slug: str
{post.coverImage && ( {post.coverImage && (
<img <img
src={post.coverImage} src={post.coverImage}
alt="Cover image" alt={`Featured image for article: ${post.title}`}
className="w-full h-64 object-cover rounded-xl my-6" className="my-8 aspect-[16/9] w-full rounded-2xl border border-border bg-surface object-cover shadow-card"
loading="lazy" loading="eager"
decoding="async"
fetchPriority="high"
/> />
)} )}
{post.tags.length > 0 && ( {post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4"> <div className="flex flex-wrap gap-2 mt-4">
{post.tags.map((tag) => ( {post.tags.map((tag) => (
<span key={tag} className="rounded-full bg-surface px-3 py-1 text-xs font-medium text-ink-soft border border-border"> <Link key={tag} href={`/tags/${tagToSlug(tag)}/`} className="rounded-full bg-surface px-3 py-1 text-xs font-medium text-ink-soft border border-border transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
{tag} {tag}
</span> </Link>
))} ))}
</div> </div>
)} )}
</header> </header>
<div className="prose prose-lg max-w-none"> <div className="prose prose-lg article-prose max-w-none min-w-0">
<PostContent /> <PostContent />
</div> </div>
</article> </article>

View File

@@ -1,6 +1,5 @@
import { getPosts } from '@/lib/posts' import { getPosts } from '@/lib/posts'
import { PostCard } from '@/components/blog/PostCard' import { PostSearch } from '@/components/posts/PostSearch'
import Template from '../template'
export const metadata = { title: 'Posts' } export const metadata = { title: 'Posts' }
@@ -8,20 +7,24 @@ export default async function PostsPage() {
const posts = await getPosts() const posts = await getPosts()
return ( return (
<Template> <main className="mx-auto max-w-5xl px-6 py-12 sm:py-16">
<main className="max-w-4xl mx-auto px-6 py-16"> <header className="mb-10 border-b border-border pb-8">
<h1 className="heading-xl text-ink mt-0 mb-12"> <h1 className="heading-xl m-0 text-ink">
Posts Posts
</h1> </h1>
<div className="space-y-12"> </header>
{posts.map((post) => (
<PostCard key={post.slug} {...post} /> {posts.length > 0 ? (
))} <PostSearch posts={posts} />
{posts.length === 0 && ( ) : (
<p className="text-ink-soft">No posts yet.</p> <section className="empty-state" aria-label="No posts published">
<p className="font-mono text-xs uppercase tracking-[0.22em] text-ink-soft">No posts yet</p>
<h2 className="heading-sm m-0 text-ink">The archive is empty.</h2>
<p className="m-0 max-w-xl text-sm leading-6 text-ink-soft">
Publish an MDX post to populate this list and enable archive search.
</p>
</section>
)} )}
</div>
</main> </main>
</Template>
) )
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { LazyMotion, domAnimation, MotionConfig } from "motion/react"; import { LazyMotion, domAnimation, MotionConfig } from "motion/react";
import { CodeCopyInit } from "@/components/ui/CodeCopyInit";
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
@@ -9,6 +10,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
reducedMotion="user" reducedMotion="user"
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
> >
<CodeCopyInit />
{children} {children}
</MotionConfig> </MotionConfig>
</LazyMotion> </LazyMotion>

55
app/tags/[tag]/page.tsx Normal file
View File

@@ -0,0 +1,55 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { PostCard } from '@/components/posts/PostCard'
import { getAllTags, getPostsByTag } from '@/lib/posts'
type TagPageProps = {
params: Promise<{ tag: string }>
}
export const dynamicParams = false
export const dynamic = 'force-static'
export async function generateStaticParams() {
const tags = await getAllTags()
return tags.map((tag) => ({ tag: tag.slug }))
}
export async function generateMetadata({ params }: TagPageProps): Promise<Metadata> {
const slug = (await params).tag
const tags = await getAllTags()
const tag = tags.find((item) => item.slug === slug)
if (!tag) return { title: 'Not Found' }
return { title: `${tag.label} posts` }
}
export default async function TagPage({ params }: TagPageProps) {
const slug = (await params).tag
const tags = await getAllTags()
const tag = tags.find((item) => item.slug === slug)
if (!tag) notFound()
const posts = await getPostsByTag(slug)
return (
<main className="max-w-4xl mx-auto px-6 py-12">
<header className="mb-12">
<p className="font-mono text-sm text-ink-soft mb-3">Tag</p>
<h1 className="heading-xl text-ink mb-3">{tag.label}</h1>
<p className="text-ink-soft">
{tag.count} {tag.count === 1 ? 'post' : 'posts'}
</p>
</header>
<ul className="space-y-8">
{posts.map((post, index) => (
<li key={post.slug}>
<PostCard {...post} index={index} />
</li>
))}
</ul>
</main>
)
}

View File

@@ -1,12 +1,13 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "new-blog", "name": "krishna-a",
"dependencies": { "dependencies": {
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
"@next/mdx": "^16.2.6", "@next/mdx": "^16.2.6",
"@rehype-pretty/transformers": "^0.13.2", "@rehype-pretty/transformers": "npm:@jsr/rehype-pretty__transformers@^0.13.4",
"@wrksz/themes": "^0.9.3", "@wrksz/themes": "^0.9.3",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"katex": "^0.17.0", "katex": "^0.17.0",
@@ -206,23 +207,21 @@
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
"@rehype-pretty/transformers": ["@rehype-pretty/transformers@0.13.2", "", {}, "sha512-p2ciQSwqy5Ip8aNUa9q6rdS/hJZXrxHYYfDVOHvKOsBu3t9HDmQ65YX6r9Qbl19vi160OAxmGF7MIoCRDJrRhg=="], "@rehype-pretty/transformers": ["@jsr/rehype-pretty__transformers@0.13.4", "https://npm.jsr.io/~/11/@jsr/rehype-pretty__transformers/0.13.4.tgz", { "dependencies": { "shiki": "^1.22.2" } }, "sha512-iGWAx/2I+5YcoZN2yB7YceBUA24ktRPAhyCBqEvkELuAmw7XCYknEqRAIm+0MdBgDv3wPlk3y6gz4IUXBf7I6w=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="], "@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="], "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="],
"@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="], "@shikijs/langs": ["@shikijs/langs@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ=="],
"@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="], "@shikijs/themes": ["@shikijs/themes@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g=="],
"@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="], "@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
@@ -486,6 +485,8 @@
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
@@ -986,9 +987,7 @@
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -1048,9 +1047,9 @@
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], "regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
@@ -1128,7 +1127,7 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="], "shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],

View File

@@ -1,52 +0,0 @@
'use client';
import Link from 'next/link';
import { m } from 'motion/react';
import type { PostMeta } from '@/lib/posts';
export function PostCard({ slug, title, date, excerpt, tags = [], author, readingTime, index = 0, coverImage }: PostMeta & { index?: number }) {
return (
<m.article
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.07 }}
viewport={{ once: true }}
className="group relative scroll-mt-20 rounded-xl border border-border bg-canvas p-6 transition-all hover:border-border/80 hover:shadow-card"
>
<Link href={`/posts/${slug}/`} className="block">
{coverImage && (
<div className="mb-4 overflow-hidden rounded-lg">
<img
src={coverImage}
alt="Cover image"
className="w-full h-40 object-cover"
loading="lazy"
/>
</div>
)}
<div className="flex items-center gap-3 text-xs text-ink-soft mb-3">
<time className="font-mono">{new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</time>
{author && <span className="text-ink-soft/70">·</span>}
{author && <span>{author}</span>}
{readingTime && <span className="text-ink-soft/70">·</span>}
{readingTime && <span className="font-mono">{readingTime} min read</span>}
</div>
<h3 className="heading-md text-ink mt-0 mb-3 group-hover:text-accent transition-colors">
{title}
</h3>
{tags && tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3">
{tags.slice(0, 3).map((tag) => (
<span key={tag} className="rounded-full bg-surface/50 border border-border px-2.5 py-0.5 text-xs text-ink-soft">
{tag}
</span>
))}
</div>
)}
{excerpt && (
<p className="text-ink-soft leading-relaxed mt-3 text-sm">{excerpt}</p>
)}
</Link>
</m.article>
);
}

View File

@@ -2,7 +2,7 @@ export function Footer() {
return ( return (
<footer className="border-t border-border mt-16 py-8 text-center"> <footer className="border-t border-border mt-16 py-8 text-center">
<p className="font-mono text-sm text-ink-soft"> <p className="font-mono text-sm text-ink-soft">
© {new Date().getFullYear()} blog. All rights reserved. © {new Date().getFullYear()} Krishna A. All rights reserved.
</p> </p>
</footer> </footer>
) )

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation";
import { m } from "motion/react"; import { m } from "motion/react";
import { ThemeToggle } from "@/components/ui/ThemeToggle"; import { ThemeToggle } from "@/components/ui/ThemeToggle";
@@ -10,27 +11,40 @@ const navLinks = [
]; ];
export function Header() { export function Header() {
const pathname = usePathname();
return ( return (
<m.nav <m.nav
aria-label="Primary navigation"
initial={{ opacity: 0, y: -12 }} initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }} transition={{ duration: 0.4, ease: "easeOut" }}
className="sticky top-0 z-50 bg-canvas/80 backdrop-blur-sm border-b border-border" className="sticky top-0 z-50 bg-canvas/80 backdrop-blur-sm border-b border-border"
> >
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between"> <div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="heading-sm text-ink hover:text-accent transition-colors"> <Link href="/" className="heading-sm text-ink hover:text-accent transition-colors">
blog Krishna
</Link> </Link>
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
{navLinks.map((link) => ( {navLinks.map((link) => {
const isActive =
link.href === "/"
? pathname === "/"
: pathname === link.href.slice(0, -1) || pathname.startsWith(link.href);
return (
<Link <Link
key={link.href} key={link.href}
href={link.href} href={link.href}
className="font-medium text-sm text-ink-soft hover:text-ink transition-colors" aria-current={isActive ? "page" : undefined}
className={`font-medium text-sm transition-colors ${
isActive ? "text-ink" : "text-ink-soft hover:text-ink"
}`}
> >
{link.label} {link.label}
</Link> </Link>
))} );
})}
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,67 @@
'use client';
import Link from 'next/link';
import { m, useReducedMotion } from 'motion/react';
import type { PostMeta } from '@/lib/posts';
const tagToClientSlug = (tag: string): string =>
tag
.normalize('NFKD')
.toLowerCase()
.replace(/[\u0300-\u036f]/g, '')
.replace(/\+/g, ' plus ')
.replace(/#/g, ' sharp ')
.replace(/[\\/]+/g, ' ')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/-{2,}/g, '-')
.replace(/^-|-$/g, '');
export function PostCard({ slug, title, date, excerpt, tags = [], author, readingTime, index = 0, coverImage }: PostMeta & { index?: number }) {
const shouldReduceMotion = useReducedMotion();
return (
<m.article
initial={shouldReduceMotion ? false : { opacity: 0, y: 14 }}
whileInView={shouldReduceMotion ? undefined : { opacity: 1, y: 0 }}
whileHover={shouldReduceMotion ? undefined : { y: -2 }}
transition={{ duration: 0.35, delay: shouldReduceMotion ? 0 : Math.min(index * 0.04, 0.18), ease: [0.22, 1, 0.36, 1] }}
viewport={{ once: true, amount: 0.2 }}
className="group relative scroll-mt-20 rounded-2xl border border-border/90 bg-canvas p-5 shadow-card transition-[background-color,border-color,box-shadow] duration-200 ease-out hover:border-ink/25 hover:bg-surface/40 hover:shadow-card-hover dark:hover:border-ink/35 sm:p-7"
>
<Link href={`/posts/${slug}/`} className="block">
{coverImage && (
<div className="mb-5 overflow-hidden rounded-xl border border-border/80 bg-surface">
<img
src={coverImage}
alt={`Cover image for ${title}`}
className="h-44 w-full object-cover transition-transform duration-300 ease-out group-hover:scale-[1.015] sm:h-52"
loading="lazy"
/>
</div>
)}
<div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-soft">
<time className="font-mono" dateTime={date}>{new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</time>
{author && <span className="text-ink-soft/70">·</span>}
{author && <span>{author}</span>}
{readingTime && <span className="text-ink-soft/70">·</span>}
{readingTime && <span className="font-mono">{readingTime} min read</span>}
</div>
<h3 className="heading-md mb-3 mt-0 text-ink transition-colors duration-200 group-hover:text-accent">
{title}
</h3>
{excerpt && (
<p className="mt-3 max-w-3xl text-sm leading-7 text-ink-soft">{excerpt}</p>
)}
</Link>
{tags && tags.length > 0 && (
<div className="mt-5 flex flex-wrap gap-2">
{tags.slice(0, 3).map((tag) => (
<Link key={tag} href={`/tags/${tagToClientSlug(tag)}/`} className="rounded-full border border-border bg-surface/70 px-3 py-1 text-xs text-ink-soft transition-colors duration-200 hover:border-accent/60 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
{tag}
</Link>
))}
</div>
)}
</m.article>
);
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { m } from "motion/react"; import { m, useReducedMotion } from "motion/react";
import { PostCard } from "./PostCard"; import { PostCard } from "./PostCard";
import type { PostMeta } from "@/lib/posts"; import type { PostMeta } from "@/lib/posts";
@@ -19,11 +19,18 @@ interface PostListProps {
} }
export function PostList({ posts }: PostListProps) { export function PostList({ posts }: PostListProps) {
const shouldReduceMotion = useReducedMotion();
return ( return (
<m.ul variants={listVariants} initial="hidden" animate="visible" className="space-y-8"> <m.ul
{posts.map((post) => ( variants={shouldReduceMotion ? undefined : listVariants}
initial={shouldReduceMotion ? false : "hidden"}
animate="visible"
className="space-y-6"
>
{posts.map((post, index) => (
<li key={post.slug}> <li key={post.slug}>
<PostCard {...post} index={posts.indexOf(post)} /> <PostCard {...post} index={index} />
</li> </li>
))} ))}
</m.ul> </m.ul>

View File

@@ -0,0 +1,237 @@
'use client'
import { useMemo, useState } from 'react'
import { PostCard } from './PostCard'
type SearchPost = {
slug: string
title: string
date: string
excerpt: string
tags: string[]
author: string | null
coverImage: string | null
readingTime: number
}
type TagFilter = {
slug: string
label: string
count: number
}
type ScoredPost = {
post: SearchPost
score: number
index: number
}
interface PostSearchProps {
posts: SearchPost[]
}
const normalizeSearchText = (value: string): string =>
value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
const tagToClientSlug = (tag: string): string =>
normalizeSearchText(tag)
.replace(/\+/g, ' plus ')
.replace(/#/g, ' sharp ')
.replace(/[\\/]+/g, ' ')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/-{2,}/g, '-')
.replace(/^-|-$/g, '')
const getOrderedCharacterScore = (field: string, query: string): number => {
if (!query) return 0
let queryIndex = 0
let gaps = 0
let lastMatchIndex = -1
for (let fieldIndex = 0; fieldIndex < field.length && queryIndex < query.length; fieldIndex += 1) {
if (field[fieldIndex] !== query[queryIndex]) continue
if (lastMatchIndex >= 0) gaps += fieldIndex - lastMatchIndex - 1
lastMatchIndex = fieldIndex
queryIndex += 1
}
if (queryIndex !== query.length) return 0
return Math.max(1, 80 - gaps - Math.max(0, field.length - query.length) * 0.05)
}
const scoreField = (field: string, query: string, weight: number): number => {
if (!field || !query) return 0
if (field === query) return 1000 * weight
if (field.startsWith(query)) return 700 * weight + query.length
if (field.includes(query)) return 500 * weight + query.length
return getOrderedCharacterScore(field, query) * weight
}
const scorePost = (post: SearchPost, query: string): number => {
if (!query) return 0
const fields = [
[post.title, 1.4],
[post.slug, 1.1],
[post.excerpt, 0.8],
[post.author ?? '', 0.75],
[post.tags.join(' '), 1],
] as const
return fields.reduce(
(score, [field, weight]) => score + scoreField(normalizeSearchText(field), query, weight),
0
)
}
const getTagFilters = (posts: SearchPost[]): TagFilter[] => {
const tags = new Map<string, TagFilter>()
posts.forEach((post) => {
const postTags = new Set<string>()
post.tags.forEach((tag) => {
const slug = tagToClientSlug(tag)
if (!slug || postTags.has(slug)) return
postTags.add(slug)
const existing = tags.get(slug)
if (existing) {
existing.count += 1
if (tag.localeCompare(existing.label) < 0) existing.label = tag
return
}
tags.set(slug, { slug, label: tag, count: 1 })
})
})
return Array.from(tags.values()).sort(
(a, b) => a.label.localeCompare(b.label) || a.slug.localeCompare(b.slug)
)
}
export function PostSearch({ posts }: PostSearchProps) {
const [query, setQuery] = useState('')
const [selectedTag, setSelectedTag] = useState<string | null>(null)
const tagFilters = useMemo(() => getTagFilters(posts), [posts])
const normalizedQuery = normalizeSearchText(query)
const results = useMemo(() => {
const scoredPosts = posts.reduce<ScoredPost[]>((matches, post, index) => {
if (selectedTag && !post.tags.some((tag) => tagToClientSlug(tag) === selectedTag)) {
return matches
}
const score = scorePost(post, normalizedQuery)
if (normalizedQuery && score <= 0) return matches
matches.push({ post, score, index })
return matches
}, [])
return scoredPosts
.sort((a, b) => b.score - a.score || a.index - b.index)
.map(({ post }) => post)
}, [normalizedQuery, posts, selectedTag])
if (posts.length === 0) {
return <p className="text-ink-soft">No posts yet.</p>
}
const hasFilters = Boolean(normalizedQuery || selectedTag)
const selectedTagLabel = tagFilters.find((tag) => tag.slug === selectedTag)?.label
return (
<section aria-labelledby="posts-search-heading" className="space-y-8">
<div className="space-y-5 rounded-xl border border-border bg-surface/30 p-4 sm:p-5">
<div className="space-y-2">
<label id="posts-search-heading" htmlFor="post-search" className="block text-sm font-medium text-ink">
Search posts
</label>
<div className="flex flex-col gap-2 sm:flex-row">
<input
id="post-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search title, excerpt, tag, author, or slug"
className="min-w-0 flex-1 rounded-lg border border-border bg-canvas px-3 py-2 text-sm text-ink outline-none transition-colors placeholder:text-ink-soft/70 focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<button
type="button"
onClick={() => setQuery('')}
disabled={!query}
className="rounded-lg border border-border px-3 py-2 text-sm text-ink-soft transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
>
Clear search
</button>
</div>
</div>
{tagFilters.length > 0 && (
<div className="space-y-2" aria-label="Filter posts by tag">
<p className="text-sm font-medium text-ink">Filter by tag</p>
<div className="flex flex-wrap gap-2">
{tagFilters.map((tag) => {
const isSelected = selectedTag === tag.slug
return (
<button
key={tag.slug}
type="button"
aria-pressed={isSelected}
onClick={() => setSelectedTag(isSelected ? null : tag.slug)}
className="rounded-full border border-border bg-canvas px-3 py-1 text-xs text-ink-soft transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent aria-pressed:border-accent aria-pressed:bg-accent/10 aria-pressed:text-accent"
>
{tag.label} <span className="font-mono text-ink-soft/70">{tag.count}</span>
</button>
)
})}
</div>
</div>
)}
<div className="flex flex-col gap-2 text-sm text-ink-soft sm:flex-row sm:items-center sm:justify-between">
<p aria-live="polite">
{results.length} {results.length === 1 ? 'post' : 'posts'} found
{selectedTagLabel ? ` in ${selectedTagLabel}` : ''}
</p>
{hasFilters && (
<button
type="button"
onClick={() => {
setQuery('')
setSelectedTag(null)
}}
className="self-start rounded-lg border border-border px-3 py-1.5 text-sm text-ink-soft transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent sm:self-auto"
>
Clear filters
</button>
)}
</div>
</div>
{results.length > 0 ? (
<div className="space-y-12">
{results.map((post, index) => (
<PostCard key={post.slug} {...post} index={index} />
))}
</div>
) : (
<p className="text-ink-soft">No posts match your search.</p>
)}
</section>
)
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef, type MouseEvent } from "react";
interface TOCItem { interface TOCItem {
id: string; id: string;
@@ -55,10 +55,26 @@ export function TableOfContents() {
return () => observerRef.current?.disconnect(); return () => observerRef.current?.disconnect();
}, []); }, []);
const handleLinkClick = (e: MouseEvent<HTMLAnchorElement>, id: string) => {
e.preventDefault();
const target = document.getElementById(id);
if (!target) return;
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
window.history.pushState(null, '', `#${id}`);
target.scrollIntoView({ behavior: 'auto' });
} else {
target.scrollIntoView({ behavior: 'smooth' });
window.history.pushState(null, '', `#${id}`);
}
};
if (headings.length === 0) return null; if (headings.length === 0) return null;
return ( return (
<nav className="lg:sticky lg:top-[var(--header-height)]"> <nav aria-label="Table of contents" className="lg:sticky lg:top-20">
<h4 className="font-sans text-xs font-semibold uppercase tracking-wider text-ink-soft mb-3"> <h4 className="font-sans text-xs font-semibold uppercase tracking-wider text-ink-soft mb-3">
On this page On this page
</h4> </h4>
@@ -67,6 +83,7 @@ export function TableOfContents() {
<li key={heading.id}> <li key={heading.id}>
<a <a
href={`#${heading.id}`} href={`#${heading.id}`}
onClick={(e) => handleLinkClick(e, heading.id)}
className={`block text-sm transition-colors ${ className={`block text-sm transition-colors ${
heading.level === 3 ? "pl-3 text-ink-soft" : "text-ink" heading.level === 3 ? "pl-3 text-ink-soft" : "text-ink"
} ${activeId === heading.id ? "font-semibold text-accent" : ""}`} } ${activeId === heading.id ? "font-semibold text-accent" : ""}`}

39
components/ui/Callout.tsx Normal file
View File

@@ -0,0 +1,39 @@
'use client';
import type { ReactNode } from 'react';
interface CalloutProps {
type?: 'note' | 'tip' | 'warning' | 'danger';
title?: string;
children: ReactNode;
}
const iconMap: Record<string, string> = {
note: '',
tip: '💡',
warning: '⚠️',
danger: '🚫',
};
const typeClasses: Record<string, string> = {
note: 'callout-note',
tip: 'callout-tip',
warning: 'callout-warning',
danger: 'callout-danger',
};
export function Callout({ type = 'note', title, children }: CalloutProps) {
const className = `callout ${typeClasses[type] || typeClasses.note}`;
return (
<div className={className} role="note">
{title && (
<div className="callout-title">
<span className="callout-icon">{iconMap[type] || iconMap.note}</span>
<span>{title}</span>
</div>
)}
<div className="callout-content">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { useEffect } from "react";
import { registerCopyButton } from "@rehype-pretty/transformers";
export function CodeCopyInit() {
useEffect(() => {
registerCopyButton();
}, []);
return null;
}

41
lib/callout-directive.js Normal file
View File

@@ -0,0 +1,41 @@
import { visit } from 'unist-util-visit';
export default function calloutDirective() {
return (tree) => {
visit(tree, (node) => {
// Handle textDirective (has content) and leafDirective (no content)
if (node.type === 'textDirective' || node.type === 'leafDirective') {
const name = node.name;
// Only handle known callout types
const validTypes = ['note', 'tip', 'warning', 'danger'];
if (!validTypes.includes(name)) return;
// Extract attributes (e.g., title="...")
const attributes = node.attributes || [];
const attrs = attributes.map((attr) => ({
type: 'mdxJsxAttribute',
name: attr.name,
value: attr.value,
}));
// Add type attribute
attrs.push({
type: 'mdxJsxAttribute',
name: 'type',
value: { type: 'mdxFlowExpression', value: `"${name}"` },
});
// Build children from node's children
const children = node.children || [];
// Transform to mdxJsxFlowElement
node.type = 'mdxJsxFlowElement';
node.name = 'Callout';
node.attributes = attrs;
node.children = children;
node.data = { hName: 'Callout', hProperties: {} };
}
});
};
}

View File

@@ -1,23 +1,49 @@
/** /**
* HAST visitor plugin that adds data-line-numbers attribute to code blocks * HAST visitor plugin that configures highlighted code blocks and adds
* inside <pre> elements. * data-line-numbers attributes inside <pre> elements.
*/ */
import rehypePrettyCode from 'rehype-pretty-code'
import { transformerCopyButton } from '@rehype-pretty/transformers'
import { visit } from 'unist-util-visit' import { visit } from 'unist-util-visit'
const prettyCodeOptions = {
theme: { light: 'github-light', dark: 'github-dark-dimmed' },
keepBackground: false,
grid: true,
transformers: [
transformerCopyButton({ jsx: true, visibility: 'hover', feedbackDuration: 2500 }),
],
}
/**
* Rehype plugin that runs pretty-code with a React-safe copy button and then
* adds line number indicators to code blocks.
* @returns {import('unified').Plugin<[], import('hast').Root>}
*/
export default function codeBlockPipeline() {
const prettyCode = rehypePrettyCode(prettyCodeOptions)
const lineNumbers = addLineNumbers()
return async function transformer(tree, file) {
await prettyCode.call(this, tree, file)
lineNumbers(tree, file)
}
}
/** /**
* Rehype plugin that adds line number indicators to code blocks. * Rehype plugin that adds line number indicators to code blocks.
* @returns {import('unified').Plugin<[], import('hast').Root>} * @returns {import('unified').Plugin<[], import('hast').Root>}
*/ */
export default function addLineNumbers() { function addLineNumbers() {
return function attacher(tree) { return function attacher(tree) {
visit(tree, 'element', function visitor(node) { visit(tree, 'element', function visitor(node) {
if ( if (node.tagName === 'pre') {
node.tagName === 'pre' && const code = node.children?.find(
node.children?.length === 1 && (child) => child.type === 'element' && child.tagName === 'code',
node.children[0].type === 'element' && )
node.children[0].tagName === 'code'
) { if (!code) return
const code = node.children[0]
code.properties = code.properties || {} code.properties = code.properties || {}
code.properties['data-line-numbers'] = '' code.properties['data-line-numbers'] = ''
} }

View File

@@ -18,10 +18,65 @@ export interface PostMeta {
export type Post = PostMeta export type Post = PostMeta
export interface TagMeta {
slug: string
label: string
count: number
}
const normalizeString = (value: unknown, fallback: string): string => {
if (typeof value === 'string') return value
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString()
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return fallback
}
const normalizeOptionalString = (value: unknown): string | null => {
if (typeof value !== 'string') return null
const normalized = value.trim()
return normalized.length > 0 ? normalized : null
}
const normalizeTags = (value: unknown): string[] => {
const values = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []
return values
.map((tag) => normalizeString(tag, '').trim())
.filter((tag) => tag.length > 0)
}
export const normalizePostData = (
slug: string,
data: Record<string, unknown>,
readingTime: number
): PostMeta => ({
slug,
title: normalizeString(data.title, slug),
date: normalizeString(data.date, 'Unknown'),
excerpt: normalizeString(data.excerpt, ''),
tags: normalizeTags(data.tags),
author: normalizeOptionalString(data.author),
coverImage: normalizeOptionalString(data.coverImage),
readingTime,
})
export const tagToSlug = (tag: string): string =>
tag
.normalize('NFKD')
.toLowerCase()
.replace(/[\u0300-\u036f]/g, '')
.replace(/\+/g, ' plus ')
.replace(/#/g, ' sharp ')
.replace(/[\\/]+/g, ' ')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/-{2,}/g, '-')
.replace(/^-|-$/g, '')
const getMdxFiles = cache(async () => { const getMdxFiles = cache(async () => {
try { try {
const files = await fs.promises.readdir(postsDirectory) const files = await fs.promises.readdir(postsDirectory)
return files.filter((f) => f.endsWith('.mdx') || f.endsWith('.md')) return files.filter((file) => file.endsWith('.mdx')).sort((a, b) => a.localeCompare(b))
} catch { } catch {
return [] return []
} }
@@ -33,22 +88,11 @@ export const getPosts = cache(async (): Promise<PostMeta[]> => {
files.map(async (file) => { files.map(async (file) => {
const filePath = path.join(postsDirectory, file) const filePath = path.join(postsDirectory, file)
const raw = await fs.promises.readFile(filePath, 'utf-8') const raw = await fs.promises.readFile(filePath, 'utf-8')
const { data } = matter(raw) const { data, content } = matter(raw)
const slug = file.replace(/\.(mdx|md)$/, '') const slug = file.replace(/\.mdx$/, '')
// Compute reading time from content (everything after frontmatter) const readingTime = getReadingTime(content)
const content = raw.split(/---\n*\n*/).slice(2).join('\n')
const readingTime = Math.max(1, Math.ceil(content.split(/\s+/).length / 200))
return { return normalizePostData(slug, data, readingTime)
slug,
title: data.title ?? slug,
date: data.date ?? 'Unknown',
excerpt: data.excerpt ?? '',
tags: data.tags ?? [],
author: data.author ?? null,
coverImage: data.coverImage ?? null,
readingTime,
}
}) })
).then((posts) => ).then((posts) =>
posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
@@ -57,29 +101,53 @@ export const getPosts = cache(async (): Promise<PostMeta[]> => {
export const getPost = async (slug: string): Promise<Post | null> => { export const getPost = async (slug: string): Promise<Post | null> => {
const files = await getMdxFiles() const files = await getMdxFiles()
const file = files.find((f) => f.replace(/\.(mdx|md)$/, '') === slug) const file = files.find((f) => f.replace(/\.mdx$/, '') === slug)
if (!file) return null if (!file) return null
const filePath = path.join(postsDirectory, file) const filePath = path.join(postsDirectory, file)
const raw = await fs.promises.readFile(filePath, 'utf8') const raw = await fs.promises.readFile(filePath, 'utf8')
const { data } = matter(raw) const { data, content } = matter(raw)
const contentForReadingTime = raw.replace(/^---[\s\S]*?---\s*?/, '') return normalizePostData(slug, data, getReadingTime(content)) satisfies Post
const readingTime = Math.max(1, Math.ceil(contentForReadingTime.split(/\s+/).filter(Boolean).length / 200))
return {
slug,
title: data.title ?? slug,
date: data.date ?? 'Unknown',
excerpt: data.excerpt ?? '',
tags: data.tags ?? [],
author: data.author ?? null,
coverImage: data.coverImage ?? null,
readingTime,
} satisfies Post
} }
export const getReadingTime = (content: string): number => { export const getReadingTime = (content: string): number => {
const words = content.replace(/<[^>]*>/g, '').trim().split(/\s+/).filter(Boolean).length const words = content.replace(/<[^>]*>/g, '').trim().split(/\s+/).filter(Boolean).length
return Math.max(1, Math.ceil(words / 200)) return Math.max(1, Math.ceil(words / 200))
} }
export const getAllTags = cache(async (): Promise<TagMeta[]> => {
const posts = await getPosts()
const tags = new Map<string, TagMeta>()
posts.forEach((post) => {
const postTagSlugs = new Set<string>()
post.tags.forEach((tag) => {
const slug = tagToSlug(tag)
if (!slug || postTagSlugs.has(slug)) return
postTagSlugs.add(slug)
const existing = tags.get(slug)
if (existing) {
existing.count += 1
if (tag.localeCompare(existing.label) < 0) existing.label = tag
return
}
tags.set(slug, { slug, label: tag, count: 1 })
})
})
return Array.from(tags.values()).sort(
(a, b) => a.label.localeCompare(b.label) || a.slug.localeCompare(b.slug)
)
})
export const getPostsByTag = cache(async (tagSlug: string): Promise<PostMeta[]> => {
const posts = await getPosts()
const normalizedTagSlug = tagToSlug(tagSlug)
return posts.filter((post) => post.tags.some((tag) => tagToSlug(tag) === normalizedTagSlug))
})

View File

@@ -1,22 +1,24 @@
import type { MDXComponents } from 'mdx/types' import type { MDXComponents } from 'mdx/types'
import { Callout } from '@/components/ui/Callout'
export function useMDXComponents(components: MDXComponents): MDXComponents { export function useMDXComponents(components: MDXComponents): MDXComponents {
return { return {
...components, ...components,
Callout,
// Typography // Typography
h1: (props) => ( h1: (props) => (
<h1 {...props} className="scroll-m-20 heading-xl text-ink mt-10 mb-4"> <h1 {...props} className="heading-xl text-ink mt-10 mb-4">
{props.children} {props.children}
</h1> </h1>
), ),
h2: (props) => ( h2: (props) => (
<h2 {...props} className="scroll-m-20 border-b border-border pb-2 heading-lg text-ink mt-12 mb-4 first:mt-0"> <h2 {...props} className="border-b border-border pb-2 heading-lg text-ink mt-12 mb-4 first:mt-0">
{props.children} {props.children}
</h2> </h2>
), ),
h3: (props) => ( h3: (props) => (
<h3 {...props} className="scroll-m-20 heading-md text-ink mt-8 mb-3"> <h3 {...props} className="heading-md text-ink mt-8 mb-3">
{props.children} {props.children}
</h3> </h3>
), ),
@@ -30,12 +32,12 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
<h6 className="text-sm font-medium uppercase tracking-wider text-ink-soft mt-6 mb-2" {...props}>{children}</h6> <h6 className="text-sm font-medium uppercase tracking-wider text-ink-soft mt-6 mb-2" {...props}>{children}</h6>
), ),
p: ({ children }) => ( p: ({ children }) => (
<p className="leading-7 [&:not(:first-child)]:mt-6 text-ink"> <p className="text-ink leading-7 [&:not(:first-child)]:mt-6">
{children} {children}
</p> </p>
), ),
blockquote: ({ children }) => ( blockquote: ({ children }) => (
<blockquote className="border-l-4 border-border pl-4 italic text-ink-soft my-6"> <blockquote className="my-6 border-l-4 border-accent/40 bg-surface/60 py-1 pl-5 pr-4 italic text-ink-soft">
{children} {children}
</blockquote> </blockquote>
), ),
@@ -75,11 +77,18 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
</pre> </pre>
), ),
img: ({ src, alt, ...rest }) => ( img: ({ src, alt, ...rest }) => (
<img src={src} alt={alt} className="my-8 rounded-xl w-full" {...rest} /> <img
src={src}
alt={alt ?? ''}
className="mx-auto my-8 h-auto max-h-[80vh] w-auto max-w-full rounded-2xl border border-border bg-surface object-contain shadow-card"
loading="lazy"
decoding="async"
{...rest}
/>
), ),
table: ({ children }) => ( table: ({ children }) => (
<div className="my-6 overflow-x-auto"> <div className="my-8 overflow-x-auto rounded-xl border border-border bg-canvas">
<table className="w-full border-collapse text-sm"> <table className="w-full min-w-[40rem] border-collapse text-sm">
{children} {children}
</table> </table>
</div> </div>
@@ -91,8 +100,8 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
li: (props) => <li className="leading-7" {...props} />, li: (props) => <li className="leading-7" {...props} />,
// Figures and captions // Figures and captions
figure: (props) => <figure className="my-8" {...props} />, figure: (props) => <figure className="my-10" {...props} />,
figcaption: (props) => <figcaption className="text-center text-sm text-ink-soft mt-2" {...props} />, figcaption: (props) => <figcaption className="mx-auto mt-3 max-w-2xl text-center text-sm leading-6 text-ink-soft" {...props} />,
// Subscripts and superscripts // Subscripts and superscripts
sup: (props) => <sup className="text-xs" {...props} />, sup: (props) => <sup className="text-xs" {...props} />,
@@ -104,7 +113,7 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
), ),
// Collapsible sections // Collapsible sections
details: (props) => <details className="my-4 rounded-lg border border-border p-4" {...props} />, details: (props) => <details className="my-6 rounded-xl border border-border bg-surface/70 p-4 shadow-card" {...props} />,
summary: (props) => <summary className="cursor-pointer font-semibold" {...props} />, summary: (props) => <summary className="cursor-pointer font-semibold text-ink" {...props} />,
} }
} }

View File

@@ -1,6 +1,9 @@
import type { NextConfig } from 'next' import type { NextConfig } from 'next'
import createMDX from '@next/mdx' import createMDX from '@next/mdx'
const mdxCodeBlockPipeline = new URL('./lib/mdx-hast-visitor.js', import.meta.url).pathname
const calloutDirectivePath = new URL('./lib/callout-directive.js', import.meta.url).pathname
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'export', output: 'export',
trailingSlash: true, trailingSlash: true,
@@ -9,13 +12,19 @@ const nextConfig: NextConfig = {
const withMDX = createMDX({ const withMDX = createMDX({
options: { options: {
remarkPlugins: ['remark-frontmatter', 'remark-smartypants', 'remark-math', 'remark-gfm'], remarkPlugins: [
'remark-frontmatter',
'remark-smartypants',
'remark-math',
'remark-gfm',
'remark-directive',
calloutDirectivePath,
],
rehypePlugins: [ rehypePlugins: [
'rehype-slug', 'rehype-slug',
['rehype-external-links', { target: '_blank', rel: ['nofollow', 'noopener', 'noreferrer'] }], ['rehype-external-links', { target: '_blank', rel: ['nofollow', 'noopener', 'noreferrer'] }],
'rehype-autolink-headings', 'rehype-autolink-headings',
['rehype-pretty-code', { theme: { light: 'github-light', dark: 'github-dark-dimmed' }, keepBackground: false, grid: true }], mdxCodeBlockPipeline,
'/mnt/blog/new-blog/lib/mdx-hast-visitor.js',
'rehype-katex', 'rehype-katex',
], ],
}, },

8847
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{ {
"name": "new-blog", "name": "krishna-a",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -12,7 +12,7 @@
"dependencies": { "dependencies": {
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
"@next/mdx": "^16.2.6", "@next/mdx": "^16.2.6",
"@rehype-pretty/transformers": "^0.13.2", "@rehype-pretty/transformers": "npm:@jsr/rehype-pretty__transformers@^0.13.4",
"@wrksz/themes": "^0.9.3", "@wrksz/themes": "^0.9.3",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"katex": "^0.17.0", "katex": "^0.17.0",
@@ -20,12 +20,13 @@
"next": "16.2.6", "next": "16.2.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"remark-frontmatter": "^5.0.0",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-external-links": "^3.0.0", "rehype-external-links": "^3.0.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-pretty-code": "^0.14.3", "rehype-pretty-code": "^0.14.3",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark-directive": "^4.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-smartypants": "^3.0.2" "remark-smartypants": "^3.0.2"

View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 540" role="img" aria-labelledby="title desc">
<title id="title">Starter design diagram</title>
<desc id="desc">Three connected nodes labeled design, content, and build.</desc>
<rect width="900" height="540" rx="36" fill="#f8fafc"/>
<path d="M250 270h400" stroke="#94a3b8" stroke-width="12" stroke-linecap="round"/>
<path d="M450 185v170" stroke="#94a3b8" stroke-width="12" stroke-linecap="round"/>
<g font-family="ui-sans-serif, system-ui, sans-serif" font-weight="700" text-anchor="middle">
<circle cx="250" cy="270" r="98" fill="#dbeafe" stroke="#2563eb" stroke-width="8"/>
<text x="250" y="282" fill="#1e3a8a" font-size="34">Design</text>
<circle cx="650" cy="270" r="98" fill="#dcfce7" stroke="#16a34a" stroke-width="8"/>
<text x="650" y="282" fill="#14532d" font-size="34">Content</text>
<circle cx="450" cy="150" r="88" fill="#fef3c7" stroke="#d97706" stroke-width="8"/>
<text x="450" y="162" fill="#78350f" font-size="32">Build</text>
<circle cx="450" cy="390" r="88" fill="#fce7f3" stroke="#db2777" stroke-width="8"/>
<text x="450" y="402" fill="#831843" font-size="32">Ship</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
<title id="title">Starter MDX showcase cover</title>
<desc id="desc">A small abstract gradient cover with cards representing math, code, and design.</desc>
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#7c3aed"/>
<stop offset="0.55" stop-color="#2563eb"/>
<stop offset="1" stop-color="#14b8a6"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="18" stdDeviation="20" flood-color="#0f172a" flood-opacity="0.25"/>
</filter>
</defs>
<rect width="1200" height="630" rx="42" fill="url(#bg)"/>
<circle cx="1030" cy="120" r="150" fill="#ffffff" opacity="0.16"/>
<circle cx="130" cy="560" r="210" fill="#ffffff" opacity="0.12"/>
<g filter="url(#shadow)">
<rect x="155" y="145" width="890" height="340" rx="32" fill="#ffffff" opacity="0.92"/>
<rect x="210" y="205" width="260" height="42" rx="12" fill="#111827" opacity="0.9"/>
<rect x="210" y="275" width="330" height="28" rx="10" fill="#6366f1" opacity="0.85"/>
<rect x="210" y="330" width="260" height="28" rx="10" fill="#0f766e" opacity="0.75"/>
<text x="615" y="255" fill="#111827" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="46" font-weight="700">MDX</text>
<text x="615" y="325" fill="#334155" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="34">$E=mc²</text>
<text x="615" y="390" fill="#334155" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="30">code · links · tables</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB