Compare commits
14 Commits
19140b9df3
...
72443140f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72443140f9 | ||
|
|
b28a542a57 | ||
|
|
05440d2d5e | ||
|
|
e8ccf144d6 | ||
|
|
f2b88f1130 | ||
|
|
d223c5f512 | ||
|
|
2a9a3f6550 | ||
|
|
72f2e88673 | ||
|
|
755a65d9a6 | ||
|
|
a897894791 | ||
|
|
381e6225e1 | ||
|
|
de13ba43b0 | ||
|
|
c510abe875 | ||
|
|
21be810a68 |
227
app/globals.css
227
app/globals.css
@@ -48,6 +48,7 @@
|
||||
|
||||
/* Shadows */
|
||||
--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 */
|
||||
--animate-fade-in: fade-in 0.3s ease-out;
|
||||
@@ -100,6 +101,11 @@
|
||||
a:hover {
|
||||
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 === */
|
||||
@@ -125,6 +131,198 @@
|
||||
--color-code-copy-hover: oklch(0.85 0 0);
|
||||
|
||||
--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: 96px;
|
||||
}
|
||||
|
||||
.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 === */
|
||||
@@ -223,15 +421,19 @@
|
||||
background-color: oklch(0.10 0 0 / 0.85);
|
||||
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;
|
||||
}
|
||||
[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;
|
||||
background-color: oklch(0.95 0 0 / 0.95);
|
||||
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);
|
||||
}
|
||||
[data-rehype-pretty-code-figure] .rehype-pretty-copy.rehype-pretty-copied {
|
||||
@@ -273,23 +475,24 @@ html.dark code[data-theme*=" "] span {
|
||||
font-size: 1.1em !important;
|
||||
}
|
||||
.katex-display {
|
||||
overflow: visible;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
max-width: 100%;
|
||||
margin: 1.5em 0;
|
||||
margin: 1.75em 0;
|
||||
padding: 0.25rem 0 0.6rem;
|
||||
text-align: center;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.katex-display > .katex {
|
||||
display: block;
|
||||
display: inline-block;
|
||||
min-width: max-content;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.katex-display > .katex {
|
||||
transform: scale(0.85);
|
||||
transform-origin: center top;
|
||||
}
|
||||
.katex-display {
|
||||
margin: 0.8em 0;
|
||||
margin: 1.25em 0;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
.prose p + .katex-display { margin-top: 0.5em; }
|
||||
|
||||
@@ -32,8 +32,8 @@ const plexMono = IBM_Plex_Mono({
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { template: '%s | blog', default: 'blog' },
|
||||
description: 'A sleek static blog with code and math.',
|
||||
title: { template: '%s | Krishna', default: 'Krishna' },
|
||||
description: 'A sleek static journal with code and math.',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
28
app/page.tsx
28
app/page.tsx
@@ -1,22 +1,30 @@
|
||||
import { getPosts } from '@/lib/posts'
|
||||
import { PostList } from '@/components/blog/PostList'
|
||||
import Template from './template'
|
||||
import { PostList } from '@/components/posts/PostList'
|
||||
|
||||
export default async function HomePage() {
|
||||
const posts = await getPosts()
|
||||
|
||||
return (
|
||||
<Template>
|
||||
<main className="max-w-4xl mx-auto px-6 py-12">
|
||||
<header className="mb-16 text-center">
|
||||
</header>
|
||||
<section>
|
||||
<h1 className="heading-xl text-ink mb-8">
|
||||
Latest
|
||||
<main className="mx-auto max-w-5xl px-6 py-10 sm:py-14">
|
||||
<section aria-labelledby="recent-heading" className="space-y-7">
|
||||
<div className="border-b border-border pb-5">
|
||||
<h1 id="recent-heading" className="heading-xl m-0 text-ink">
|
||||
Recent
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<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>
|
||||
</main>
|
||||
</Template>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPosts, getPost } from '@/lib/posts'
|
||||
import { TableOfContents } from '@/components/blog/TableOfContents'
|
||||
import Link from 'next/link'
|
||||
import { getPosts, getPost, tagToSlug } from '@/lib/posts'
|
||||
import { TableOfContents } from '@/components/posts/TableOfContents'
|
||||
import { ScrollToTop } from '@/components/ui/ScrollToTop'
|
||||
import { ReadingProgress } from '@/components/ui/ReadingProgress'
|
||||
|
||||
export const dynamicParams = false
|
||||
export const dynamic = 'force-static'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = await getPosts()
|
||||
if (posts.length === 0) {
|
||||
return [{ slug: '__placeholder__' }]
|
||||
}
|
||||
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 post = await getPost(slug)
|
||||
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 }> }) {
|
||||
@@ -34,11 +35,11 @@ export default async function PostPage({ params }: { params: Promise<{ slug: str
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<ReadingProgress />
|
||||
<div className="max-w-4xl mx-auto px-6 py-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_200px] gap-8">
|
||||
<article>
|
||||
<div className="mx-auto max-w-6xl px-6 py-16">
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_220px] lg:gap-12">
|
||||
<article className="min-w-0">
|
||||
<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">
|
||||
{post.title}
|
||||
</h1>
|
||||
@@ -49,22 +50,24 @@ export default async function PostPage({ params }: { params: Promise<{ slug: str
|
||||
{post.coverImage && (
|
||||
<img
|
||||
src={post.coverImage}
|
||||
alt="Cover image"
|
||||
className="w-full h-64 object-cover rounded-xl my-6"
|
||||
loading="lazy"
|
||||
alt={`Featured image for article: ${post.title}`}
|
||||
className="my-8 aspect-[16/9] w-full rounded-2xl border border-border bg-surface object-cover shadow-card"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
)}
|
||||
{post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{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}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<div className="prose prose-lg article-prose max-w-none min-w-0">
|
||||
<PostContent />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getPosts } from '@/lib/posts'
|
||||
import { PostCard } from '@/components/blog/PostCard'
|
||||
import Template from '../template'
|
||||
import { PostSearch } from '@/components/posts/PostSearch'
|
||||
|
||||
export const metadata = { title: 'Posts' }
|
||||
|
||||
@@ -8,20 +7,24 @@ export default async function PostsPage() {
|
||||
const posts = await getPosts()
|
||||
|
||||
return (
|
||||
<Template>
|
||||
<main className="max-w-4xl mx-auto px-6 py-16">
|
||||
<h1 className="heading-xl text-ink mt-0 mb-12">
|
||||
<main className="mx-auto max-w-5xl px-6 py-12 sm:py-16">
|
||||
<header className="mb-10 border-b border-border pb-8">
|
||||
<h1 className="heading-xl m-0 text-ink">
|
||||
Posts
|
||||
</h1>
|
||||
<div className="space-y-12">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.slug} {...post} />
|
||||
))}
|
||||
{posts.length === 0 && (
|
||||
<p className="text-ink-soft">No posts yet.</p>
|
||||
</header>
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<PostSearch posts={posts} />
|
||||
) : (
|
||||
<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>
|
||||
</Template>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { LazyMotion, domAnimation, MotionConfig } from "motion/react";
|
||||
import { CodeCopyInit } from "@/components/ui/CodeCopyInit";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -9,6 +10,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
reducedMotion="user"
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<CodeCopyInit />
|
||||
{children}
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
|
||||
55
app/tags/[tag]/page.tsx
Normal file
55
app/tags/[tag]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
bun.lock
33
bun.lock
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "new-blog",
|
||||
"name": "krishna-a",
|
||||
"dependencies": {
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@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",
|
||||
"gray-matter": "^4.0.3",
|
||||
"katex": "^0.17.0",
|
||||
@@ -206,23 +207,21 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="],
|
||||
"@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/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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1128,7 +1127,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border mt-16 py-8 text-center">
|
||||
<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>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { m } from "motion/react";
|
||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||
|
||||
@@ -10,8 +11,11 @@ const navLinks = [
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<m.nav
|
||||
aria-label="Primary navigation"
|
||||
initial={{ opacity: 0, y: -12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
@@ -19,18 +23,28 @@ export function Header() {
|
||||
>
|
||||
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<Link href="/" className="heading-sm text-ink hover:text-accent transition-colors">
|
||||
blog
|
||||
Krishna
|
||||
</Link>
|
||||
<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
|
||||
key={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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
67
components/posts/PostCard.tsx
Normal file
67
components/posts/PostCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { m } from "motion/react";
|
||||
import { m, useReducedMotion } from "motion/react";
|
||||
import { PostCard } from "./PostCard";
|
||||
import type { PostMeta } from "@/lib/posts";
|
||||
|
||||
@@ -19,11 +19,18 @@ interface PostListProps {
|
||||
}
|
||||
|
||||
export function PostList({ posts }: PostListProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<m.ul variants={listVariants} initial="hidden" animate="visible" className="space-y-8">
|
||||
{posts.map((post) => (
|
||||
<m.ul
|
||||
variants={shouldReduceMotion ? undefined : listVariants}
|
||||
initial={shouldReduceMotion ? false : "hidden"}
|
||||
animate="visible"
|
||||
className="space-y-6"
|
||||
>
|
||||
{posts.map((post, index) => (
|
||||
<li key={post.slug}>
|
||||
<PostCard {...post} index={posts.indexOf(post)} />
|
||||
<PostCard {...post} index={index} />
|
||||
</li>
|
||||
))}
|
||||
</m.ul>
|
||||
237
components/posts/PostSearch.tsx
Normal file
237
components/posts/PostSearch.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function TableOfContents() {
|
||||
if (headings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav className="lg:sticky lg:top-[var(--header-height)]">
|
||||
<nav aria-label="Table of contents" className="lg:sticky lg:top-[var(--header-height)]">
|
||||
<h4 className="font-sans text-xs font-semibold uppercase tracking-wider text-ink-soft mb-3">
|
||||
On this page
|
||||
</h4>
|
||||
12
components/ui/CodeCopyInit.tsx
Normal file
12
components/ui/CodeCopyInit.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { registerCopyButton } from "@rehype-pretty/transformers";
|
||||
|
||||
export function CodeCopyInit() {
|
||||
useEffect(() => {
|
||||
registerCopyButton();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
33
content/posts/designing-with-nextjs.mdx
Normal file
33
content/posts/designing-with-nextjs.mdx
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "Designing With Next.js"
|
||||
date: "2026-05-22"
|
||||
excerpt: "A short sample note about pairing interface design decisions with static Next.js content."
|
||||
tags:
|
||||
- Design
|
||||
- Next.js
|
||||
- Web Dev
|
||||
author: "Starter Content"
|
||||
coverImage: "/starter-diagram.svg"
|
||||
---
|
||||
|
||||
This sample note gives tag pages another realistic post to list. It is intentionally brief and ready to replace.
|
||||
|
||||

|
||||
|
||||
## A tiny design checklist
|
||||
|
||||
- Keep headings scannable.
|
||||
- Pair every visual with helpful alt text.
|
||||
- Use consistent tags so archive pages feel populated.
|
||||
|
||||
The same content can support readers and tooling: humans get structure, while static route generation gets predictable params.
|
||||
|
||||
```ts title="content-tags.ts" {3}
|
||||
export const starterTags = [
|
||||
'Design',
|
||||
'Next.js',
|
||||
'Web Dev',
|
||||
]
|
||||
```
|
||||
|
||||
> Replace this with a real design journal, launch note, or tutorial when you are ready.
|
||||
33
content/posts/math-notes-for-builders.mdx
Normal file
33
content/posts/math-notes-for-builders.mdx
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "Math Notes for Builders"
|
||||
date: "2026-05-08"
|
||||
excerpt: "A compact starter post that keeps Math and Code tags populated while checking equation rendering."
|
||||
tags:
|
||||
- Math
|
||||
- Code
|
||||
- Web Dev
|
||||
author: "Starter Content"
|
||||
---
|
||||
|
||||
This replaceable note exists to keep the **Math**, **Code**, and **Web Dev** tag pages meaningful during early validation.
|
||||
|
||||
## Tiny model
|
||||
|
||||
Inline math can express a quick estimate like $n \log n$, while display math can show the full relationship:
|
||||
|
||||
$$
|
||||
T(n) = O(n \log n) + O(k)
|
||||
$$
|
||||
|
||||
## Practical reminder
|
||||
|
||||
- Prefer simple explanations before formulas.
|
||||
- Add runnable code when it clarifies an idea.
|
||||
- Link back to related posts, such as the [starter showcase](/posts/starter-mdx-showcase/).
|
||||
|
||||
```js title="estimate.js" {1,4}
|
||||
export function estimate(items, constant = 1) {
|
||||
const n = Math.max(items.length, 1)
|
||||
return n * Math.log2(n) + constant
|
||||
}
|
||||
```
|
||||
73
content/posts/starter-mdx-showcase.mdx
Normal file
73
content/posts/starter-mdx-showcase.mdx
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "Starter MDX Showcase"
|
||||
date: "2026-06-03"
|
||||
excerpt: "Sample starter content demonstrating math, local images, links, tables, tasks, and code blocks for validating site routes."
|
||||
tags:
|
||||
- Design
|
||||
- Next.js
|
||||
- Math
|
||||
- Code
|
||||
- Web Dev
|
||||
author: "Starter Content"
|
||||
coverImage: "/starter-showcase.svg"
|
||||
---
|
||||
|
||||
This is deliberately sample content for Krishna's journal. Replace it with your own writing once the routes, tags, and search experience are validated.
|
||||
|
||||

|
||||
|
||||
## What this post covers
|
||||
|
||||
- A local image from `public/` referenced with an absolute path.
|
||||
- Links to [Next.js](https://nextjs.org/) and an internal [tag page](/tags/web-dev/).
|
||||
- GFM tables, task lists, blockquotes, code fences, and math.
|
||||
|
||||
> Starter posts should be easy to delete, but rich enough to prove the publishing pipeline works end to end.
|
||||
|
||||
## Markdown and GFM examples
|
||||
|
||||
| Feature | Purpose | Status |
|
||||
| --- | --- | --- |
|
||||
| Tags | Builds static tag pages | Ready |
|
||||
| Math | Checks KaTeX rendering | Ready |
|
||||
| Code | Checks syntax highlighting | Ready |
|
||||
|
||||
- [x] Confirm route generation has at least one post.
|
||||
- [x] Confirm spaced tags such as **Web Dev** create usable params.
|
||||
- [ ] Replace this demo with a real article.
|
||||
|
||||
Inline code like `generateStaticParams` should be readable inside a sentence, and inline math such as $E = mc^2$ should render without extra setup.
|
||||
|
||||
Display math is useful for longer equations:
|
||||
|
||||
$$
|
||||
\operatorname{score}(post) = \frac{links + images + code}{reading\ time}
|
||||
$$
|
||||
|
||||
## Code with title and highlighted lines
|
||||
|
||||
```tsx title="components/example-card.tsx" {2,6-8}
|
||||
type ExampleCardProps = {
|
||||
title: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export function ExampleCard({ title, href }: ExampleCardProps) {
|
||||
return <a href={href}>{title}</a>
|
||||
}
|
||||
```
|
||||
|
||||
## Lists, links, and details
|
||||
|
||||
1. Draft the article in MDX.
|
||||
2. Add concrete examples and screenshots.
|
||||
3. Link to useful references, such as the [MDX docs](https://mdxjs.com/).
|
||||
|
||||
<details>
|
||||
<summary>Why keep demo content obvious?</summary>
|
||||
<p>Because starter posts are fixtures for validation, not permanent editorial content.</p>
|
||||
</details>
|
||||
|
||||
## Closing note
|
||||
|
||||
This post intentionally exercises common authoring features so the owner can validate static post routes, tag pages, and future search indexing with realistic but replaceable content.
|
||||
@@ -1,23 +1,49 @@
|
||||
/**
|
||||
* HAST visitor plugin that adds data-line-numbers attribute to code blocks
|
||||
* inside <pre> elements.
|
||||
* HAST visitor plugin that configures highlighted code blocks and adds
|
||||
* 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'
|
||||
|
||||
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.
|
||||
* @returns {import('unified').Plugin<[], import('hast').Root>}
|
||||
*/
|
||||
export default function addLineNumbers() {
|
||||
function addLineNumbers() {
|
||||
return function attacher(tree) {
|
||||
visit(tree, 'element', function visitor(node) {
|
||||
if (
|
||||
node.tagName === 'pre' &&
|
||||
node.children?.length === 1 &&
|
||||
node.children[0].type === 'element' &&
|
||||
node.children[0].tagName === 'code'
|
||||
) {
|
||||
const code = node.children[0]
|
||||
if (node.tagName === 'pre') {
|
||||
const code = node.children?.find(
|
||||
(child) => child.type === 'element' && child.tagName === 'code',
|
||||
)
|
||||
|
||||
if (!code) return
|
||||
|
||||
code.properties = code.properties || {}
|
||||
code.properties['data-line-numbers'] = ''
|
||||
}
|
||||
|
||||
130
lib/posts.ts
130
lib/posts.ts
@@ -18,10 +18,65 @@ export interface 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 () => {
|
||||
try {
|
||||
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 {
|
||||
return []
|
||||
}
|
||||
@@ -33,22 +88,11 @@ export const getPosts = cache(async (): Promise<PostMeta[]> => {
|
||||
files.map(async (file) => {
|
||||
const filePath = path.join(postsDirectory, file)
|
||||
const raw = await fs.promises.readFile(filePath, 'utf-8')
|
||||
const { data } = matter(raw)
|
||||
const slug = file.replace(/\.(mdx|md)$/, '')
|
||||
// Compute reading time from content (everything after frontmatter)
|
||||
const content = raw.split(/---\n*\n*/).slice(2).join('\n')
|
||||
const readingTime = Math.max(1, Math.ceil(content.split(/\s+/).length / 200))
|
||||
const { data, content } = matter(raw)
|
||||
const slug = file.replace(/\.mdx$/, '')
|
||||
const readingTime = getReadingTime(content)
|
||||
|
||||
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,
|
||||
}
|
||||
return normalizePostData(slug, data, readingTime)
|
||||
})
|
||||
).then((posts) =>
|
||||
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> => {
|
||||
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
|
||||
|
||||
const filePath = path.join(postsDirectory, file)
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8')
|
||||
const { data } = matter(raw)
|
||||
const { data, content } = matter(raw)
|
||||
|
||||
const contentForReadingTime = raw.replace(/^---[\s\S]*?---\s*?/, '')
|
||||
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
|
||||
return normalizePostData(slug, data, getReadingTime(content)) satisfies Post
|
||||
}
|
||||
|
||||
export const getReadingTime = (content: string): number => {
|
||||
const words = content.replace(/<[^>]*>/g, '').trim().split(/\s+/).filter(Boolean).length
|
||||
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))
|
||||
})
|
||||
|
||||
@@ -30,12 +30,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>
|
||||
),
|
||||
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}
|
||||
</p>
|
||||
),
|
||||
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}
|
||||
</blockquote>
|
||||
),
|
||||
@@ -75,11 +75,18 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
</pre>
|
||||
),
|
||||
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 }) => (
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<div className="my-8 overflow-x-auto rounded-xl border border-border bg-canvas">
|
||||
<table className="w-full min-w-[40rem] border-collapse text-sm">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
@@ -91,8 +98,8 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
li: (props) => <li className="leading-7" {...props} />,
|
||||
|
||||
// Figures and captions
|
||||
figure: (props) => <figure className="my-8" {...props} />,
|
||||
figcaption: (props) => <figcaption className="text-center text-sm text-ink-soft mt-2" {...props} />,
|
||||
figure: (props) => <figure className="my-10" {...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
|
||||
sup: (props) => <sup className="text-xs" {...props} />,
|
||||
@@ -104,7 +111,7 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
),
|
||||
|
||||
// Collapsible sections
|
||||
details: (props) => <details className="my-4 rounded-lg border border-border p-4" {...props} />,
|
||||
summary: (props) => <summary className="cursor-pointer font-semibold" {...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 text-ink" {...props} />,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { NextConfig } from 'next'
|
||||
import createMDX from '@next/mdx'
|
||||
|
||||
const mdxCodeBlockPipeline = new URL('./lib/mdx-hast-visitor.js', import.meta.url).pathname
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
@@ -14,8 +16,7 @@ const withMDX = createMDX({
|
||||
'rehype-slug',
|
||||
['rehype-external-links', { target: '_blank', rel: ['nofollow', 'noopener', 'noreferrer'] }],
|
||||
'rehype-autolink-headings',
|
||||
['rehype-pretty-code', { theme: { light: 'github-light', dark: 'github-dark-dimmed' }, keepBackground: false, grid: true }],
|
||||
'/mnt/blog/new-blog/lib/mdx-hast-visitor.js',
|
||||
mdxCodeBlockPipeline,
|
||||
'rehype-katex',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "new-blog",
|
||||
"name": "krishna-a",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@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",
|
||||
"gray-matter": "^4.0.3",
|
||||
"katex": "^0.17.0",
|
||||
|
||||
17
public/starter-diagram.svg
Normal file
17
public/starter-diagram.svg
Normal 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 |
26
public/starter-showcase.svg
Normal file
26
public/starter-showcase.svg
Normal 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 |
Reference in New Issue
Block a user