feat/intl-multi-lang #10

Merged
raresj merged 9 commits from feat/intl-multi-lang into staging 2025-12-02 22:29:26 +00:00
26 changed files with 871 additions and 274 deletions
Showing only changes of commit 1042a43dfa - Show all commits

6
.gitignore vendored
View File

@@ -16,3 +16,9 @@ yarn-error.log*
.vercel .vercel
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Build artifacts (copied images)
public/blog/**/*.jpg
public/blog/**/*.png
public/blog/**/*.webp
public/blog/**/*.gif

View File

@@ -5,7 +5,7 @@
# ============================================ # ============================================
# Stage 1: Dependencies Installation # Stage 1: Dependencies Installation
# ============================================ # ============================================
FROM node:20-alpine AS deps FROM node:22-alpine AS deps
# Install libc6-compat for better compatibility # Install libc6-compat for better compatibility
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
@@ -24,7 +24,7 @@ RUN npm ci
# ============================================ # ============================================
# Stage 2: Build Next.js Application # Stage 2: Build Next.js Application
# ============================================ # ============================================
FROM node:20-alpine AS builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -57,7 +57,7 @@ RUN npm run build
# ============================================ # ============================================
# Stage 3: Production Runtime # Stage 3: Production Runtime
# ============================================ # ============================================
FROM node:20-alpine AS runner FROM node:22-alpine AS runner
# Install curl for health checks # Install curl for health checks
RUN apk add --no-cache curl RUN apk add --no-cache curl

View File

@@ -24,7 +24,7 @@ export default async function BlogPostBreadcrumb({
}) { }) {
const { slug } = await params const { slug } = await params
const slugPath = slug.join('/') const slugPath = slug.join('/')
const post = getPostBySlug(slugPath) const post = await getPostBySlug(slugPath)
const items: BreadcrumbItem[] = [ const items: BreadcrumbItem[] = [
{ {

View File

@@ -20,7 +20,7 @@ export async function generateMetadata({
}): Promise<Metadata> { }): Promise<Metadata> {
const { slug } = await params const { slug } = await params
const slugPath = slug.join('/') const slugPath = slug.join('/')
const post = getPostBySlug(slugPath) const post = await getPostBySlug(slugPath)
if (!post) { if (!post) {
return { title: 'Articol negăsit' } return { title: 'Articol negăsit' }
@@ -68,7 +68,7 @@ function extractHeadings(content: string) {
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) { export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params const { slug } = await params
const slugPath = slug.join('/') const slugPath = slug.join('/')
const post = getPostBySlug(slugPath) const post = await getPostBySlug(slugPath)
if (!post) { if (!post) {
notFound() notFound()

View File

@@ -1,4 +1,4 @@
import Link from 'next/link'; import Link from 'next/link'
export default function TagNotFound() { export default function TagNotFound() {
return ( return (
@@ -36,5 +36,5 @@ export default function TagNotFound() {
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@@ -1,30 +1,25 @@
import { Metadata } from 'next'; import { Metadata } from 'next'
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation'
import Link from 'next/link'; import Link from 'next/link'
import { import { getAllTags, getPostsByTag, getTagInfo, getRelatedTags } from '@/lib/tags'
getAllTags, import { TagList } from '@/components/blog/tag-list'
getPostsByTag, import { formatDate } from '@/lib/utils'
getTagInfo,
getRelatedTags
} from '@/lib/tags';
import { TagList } from '@/components/blog/tag-list';
import { formatDate } from '@/lib/utils';
export async function generateStaticParams() { export async function generateStaticParams() {
const tags = await getAllTags(); const tags = await getAllTags()
return tags.map(tag => ({ tag: tag.slug })); return tags.map(tag => ({ tag: tag.slug }))
} }
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: {
params: Promise<{ tag: string }>; params: Promise<{ tag: string }>
}): Promise<Metadata> { }): Promise<Metadata> {
const { tag } = await params; const { tag } = await params
const tagInfo = await getTagInfo(tag); const tagInfo = await getTagInfo(tag)
if (!tagInfo) { if (!tagInfo) {
return { title: 'Tag negăsit' }; return { title: 'Tag negăsit' }
} }
return { return {
@@ -34,7 +29,7 @@ export async function generateMetadata({
title: `Tag: ${tagInfo.name}`, title: `Tag: ${tagInfo.name}`,
description: `Explorează ${tagInfo.count} articole despre ${tagInfo.name}`, description: `Explorează ${tagInfo.count} articole despre ${tagInfo.name}`,
}, },
}; }
} }
function PostCard({ post }: { post: any }) { function PostCard({ post }: { post: any }) {
@@ -49,47 +44,34 @@ function PostCard({ post }: { post: any }) {
)} )}
<div className="flex items-center gap-2 font-mono text-xs text-zinc-500 mb-3 uppercase"> <div className="flex items-center gap-2 font-mono text-xs text-zinc-500 mb-3 uppercase">
<time dateTime={post.frontmatter.date}> <time dateTime={post.frontmatter.date}>{formatDate(post.frontmatter.date)}</time>
{formatDate(post.frontmatter.date)}
</time>
<span>&gt;</span> <span>&gt;</span>
<span>{post.readingTime} min</span> <span>{post.readingTime} min</span>
</div> </div>
<h2 className="font-mono text-lg font-bold mb-3 uppercase"> <h2 className="font-mono text-lg font-bold mb-3 uppercase">
<Link <Link href={`/blog/${post.slug}`} className="text-cyan-400 hover:text-cyan-300 transition">
href={`/blog/${post.slug}`}
className="text-cyan-400 hover:text-cyan-300 transition"
>
{post.frontmatter.title} {post.frontmatter.title}
</Link> </Link>
</h2> </h2>
<p className="text-zinc-400 mb-4 line-clamp-3"> <p className="text-zinc-400 mb-4 line-clamp-3">{post.frontmatter.description}</p>
{post.frontmatter.description}
</p>
{post.frontmatter.tags && ( {post.frontmatter.tags && <TagList tags={post.frontmatter.tags} variant="minimal" />}
<TagList tags={post.frontmatter.tags} variant="minimal" />
)}
</article> </article>
); )
} }
export default async function TagPage({ export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
params, const { tag } = await params
}: { const tagInfo = await getTagInfo(tag)
params: Promise<{ tag: string }>;
}) {
const { tag } = await params;
const tagInfo = await getTagInfo(tag);
if (!tagInfo) { if (!tagInfo) {
notFound(); notFound()
} }
const posts = await getPostsByTag(tag); const posts = await getPostsByTag(tag)
const relatedTags = await getRelatedTags(tag); const relatedTags = await getRelatedTags(tag)
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100"> <div className="min-h-screen bg-slate-950 text-slate-100">
@@ -122,9 +104,7 @@ export default async function TagPage({
<div className="lg:col-span-3"> <div className="lg:col-span-3">
{posts.length === 0 ? ( {posts.length === 0 ? (
<div className="border-4 border-slate-800 bg-slate-900 p-12 text-center"> <div className="border-4 border-slate-800 bg-slate-900 p-12 text-center">
<p className="font-mono text-zinc-400 mb-6 uppercase"> <p className="font-mono text-zinc-400 mb-6 uppercase">&gt; NO DOCUMENTS FOUND</p>
&gt; NO DOCUMENTS FOUND
</p>
<Link <Link
href="/blog" href="/blog"
className="inline-block px-6 py-3 font-mono text-sm uppercase border-2 border-cyan-400 bg-cyan-900 text-cyan-100 hover:bg-cyan-800 transition" className="inline-block px-6 py-3 font-mono text-sm uppercase border-2 border-cyan-400 bg-cyan-900 text-cyan-100 hover:bg-cyan-800 transition"
@@ -156,12 +136,8 @@ export default async function TagPage({
href={`/tags/${tag.slug}`} href={`/tags/${tag.slug}`}
className="flex items-center justify-between p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition" className="flex items-center justify-between p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition"
> >
<span className="font-mono text-xs uppercase text-zinc-300"> <span className="font-mono text-xs uppercase text-zinc-300">#{tag.name}</span>
#{tag.name} <span className="font-mono text-xs text-zinc-500">[{tag.count}]</span>
</span>
<span className="font-mono text-xs text-zinc-500">
[{tag.count}]
</span>
</Link> </Link>
))} ))}
</div> </div>
@@ -170,9 +146,7 @@ export default async function TagPage({
<div className="border-2 border-slate-700 bg-slate-900 p-6"> <div className="border-2 border-slate-700 bg-slate-900 p-6">
<div className="border-b-2 border-slate-700 pb-3 mb-4"> <div className="border-b-2 border-slate-700 pb-3 mb-4">
<h2 className="font-mono text-sm font-bold uppercase text-cyan-400"> <h2 className="font-mono text-sm font-bold uppercase text-cyan-400">QUICK NAV</h2>
QUICK NAV
</h2>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Link <Link
@@ -199,5 +173,5 @@ export default async function TagPage({
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@@ -1,17 +1,17 @@
import { Metadata } from 'next'; import { Metadata } from 'next'
import Link from 'next/link'; import Link from 'next/link'
import { getAllTags, getTagCloud } from '@/lib/tags'; import { getAllTags, getTagCloud } from '@/lib/tags'
import { TagCloud } from '@/components/blog/tag-cloud'; import { TagCloud } from '@/components/blog/tag-cloud'
import { TagBadge } from '@/components/blog/tag-badge'; import { TagBadge } from '@/components/blog/tag-badge'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Tag-uri', title: 'Tag-uri',
description: 'Explorează articolele după tag-uri', description: 'Explorează articolele după tag-uri',
}; }
export default async function TagsPage() { export default async function TagsPage() {
const allTags = await getAllTags(); const allTags = await getAllTags()
const tagCloud = await getTagCloud(); const tagCloud = await getTagCloud()
if (allTags.length === 0) { if (allTags.length === 0) {
return ( return (
@@ -21,9 +21,7 @@ export default async function TagsPage() {
<h1 className="font-mono text-3xl font-bold uppercase mb-4 text-cyan-400"> <h1 className="font-mono text-3xl font-bold uppercase mb-4 text-cyan-400">
TAG DATABASE TAG DATABASE
</h1> </h1>
<p className="font-mono text-zinc-400 mb-8"> <p className="font-mono text-zinc-400 mb-8">&gt; NO TAGS AVAILABLE</p>
&gt; NO TAGS AVAILABLE
</p>
<Link <Link
href="/blog" href="/blog"
className="inline-block px-6 py-3 font-mono text-sm uppercase border-2 border-cyan-400 bg-cyan-900 text-cyan-100 hover:bg-cyan-800 transition" className="inline-block px-6 py-3 font-mono text-sm uppercase border-2 border-cyan-400 bg-cyan-900 text-cyan-100 hover:bg-cyan-800 transition"
@@ -33,19 +31,22 @@ export default async function TagsPage() {
</div> </div>
</div> </div>
</div> </div>
); )
} }
const groupedTags = allTags.reduce((acc, tag) => { const groupedTags = allTags.reduce(
const firstLetter = tag.name[0].toUpperCase(); (acc, tag) => {
if (!acc[firstLetter]) { const firstLetter = tag.name[0].toUpperCase()
acc[firstLetter] = []; if (!acc[firstLetter]) {
} acc[firstLetter] = []
acc[firstLetter].push(tag); }
return acc; acc[firstLetter].push(tag)
}, {} as Record<string, typeof allTags>); return acc
},
{} as Record<string, typeof allTags>
)
const sortedLetters = Object.keys(groupedTags).sort(); const sortedLetters = Object.keys(groupedTags).sort()
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100"> <div className="min-h-screen bg-slate-950 text-slate-100">
@@ -57,9 +58,7 @@ export default async function TagsPage() {
<h1 className="font-mono text-4xl font-bold uppercase text-cyan-400 mb-4"> <h1 className="font-mono text-4xl font-bold uppercase text-cyan-400 mb-4">
TAG REGISTRY TAG REGISTRY
</h1> </h1>
<p className="font-mono text-lg text-zinc-400"> <p className="font-mono text-lg text-zinc-400">&gt; TOTAL TAGS: {allTags.length}</p>
&gt; TOTAL TAGS: {allTags.length}
</p>
</div> </div>
<section className="mb-12"> <section className="mb-12">
@@ -109,38 +108,28 @@ export default async function TagsPage() {
<p className="font-mono text-xs text-cyan-600 uppercase tracking-widest mb-2"> <p className="font-mono text-xs text-cyan-600 uppercase tracking-widest mb-2">
DOCUMENT STATISTICS DOCUMENT STATISTICS
</p> </p>
<h2 className="font-mono text-2xl font-bold uppercase text-cyan-400"> <h2 className="font-mono text-2xl font-bold uppercase text-cyan-400">TAG METRICS</h2>
TAG METRICS
</h2>
</div> </div>
<div className="grid gap-6 sm:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-3">
<div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center"> <div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
<div className="font-mono text-3xl font-bold text-cyan-400"> <div className="font-mono text-3xl font-bold text-cyan-400">{allTags.length}</div>
{allTags.length} <div className="font-mono text-xs text-zinc-400 uppercase mt-2">TOTAL TAGS</div>
</div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2">
TOTAL TAGS
</div>
</div> </div>
<div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center"> <div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
<div className="font-mono text-3xl font-bold text-cyan-400"> <div className="font-mono text-3xl font-bold text-cyan-400">
{Math.max(...allTags.map(t => t.count))} {Math.max(...allTags.map(t => t.count))}
</div> </div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2"> <div className="font-mono text-xs text-zinc-400 uppercase mt-2">MAX POSTS/TAG</div>
MAX POSTS/TAG
</div>
</div> </div>
<div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center"> <div className="border-2 border-cyan-700 bg-slate-900 p-6 text-center">
<div className="font-mono text-3xl font-bold text-cyan-400"> <div className="font-mono text-3xl font-bold text-cyan-400">
{Math.round(allTags.reduce((sum, t) => sum + t.count, 0) / allTags.length)} {Math.round(allTags.reduce((sum, t) => sum + t.count, 0) / allTags.length)}
</div> </div>
<div className="font-mono text-xs text-zinc-400 uppercase mt-2"> <div className="font-mono text-xs text-zinc-400 uppercase mt-2">AVG POSTS/TAG</div>
AVG POSTS/TAG
</div>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
</div> </div>
); )
} }

View File

@@ -0,0 +1,79 @@
'use client'
import { useState } from 'react'
import { OptimizedImage } from './OptimizedImage'
interface ImageItem {
src: string
alt: string
caption?: string
}
interface ImageGalleryProps {
images: ImageItem[]
columns?: 2 | 3 | 4
className?: string
}
export function ImageGallery({ images, columns = 3, className = '' }: ImageGalleryProps) {
const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null)
const gridCols = {
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
}
return (
<>
<div className={`grid ${gridCols[columns]} gap-4 ${className}`}>
{images.map((image, index) => (
<button
key={index}
onClick={() => setSelectedImage(image)}
className="group relative overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/50 transition-all hover:border-emerald-500 hover:shadow-lg hover:shadow-emerald-500/20"
>
<div className="aspect-video relative">
<img
src={image.src}
alt={image.alt}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</div>
{image.caption && <div className="p-2 text-sm text-zinc-400">{image.caption}</div>}
</button>
))}
</div>
{selectedImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
onClick={() => setSelectedImage(null)}
>
<button
className="absolute top-4 right-4 text-zinc-400 hover:text-zinc-100 transition-colors"
onClick={() => setSelectedImage(null)}
>
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<div className="max-w-5xl w-full" onClick={e => e.stopPropagation()}>
<OptimizedImage
src={selectedImage.src}
alt={selectedImage.alt}
caption={selectedImage.caption}
width={1200}
height={800}
/>
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import Image from 'next/image'
import { useState } from 'react'
interface OptimizedImageProps {
src: string
alt: string
caption?: string
width?: number
height?: number
priority?: boolean
className?: string
}
export function OptimizedImage({
src,
alt,
caption,
width = 800,
height = 600,
priority = false,
className = '',
}: OptimizedImageProps) {
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
if (hasError) {
return (
<div className="my-8 rounded-lg border border-zinc-800 bg-zinc-900/50 p-8 text-center">
<p className="text-zinc-400">Failed to load image</p>
{caption && <p className="mt-2 text-sm text-zinc-500">{caption}</p>}
</div>
)
}
return (
<figure className={`my-8 ${className}`}>
<div className="relative overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/50">
<Image
src={src}
alt={alt}
width={width}
height={height}
priority={priority}
className={`w-full h-auto transition-opacity duration-300 ${
isLoading ? 'opacity-0' : 'opacity-100'
}`}
onLoad={() => setIsLoading(false)}
onError={() => setHasError(true)}
placeholder="blur"
blurDataURL="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Crect width='400' height='300' fill='%2318181b'/%3E%3C/svg%3E"
/>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
)}
</div>
{caption && (
<figcaption className="mt-3 text-center text-sm text-zinc-400">{caption}</figcaption>
)}
</figure>
)
}

View File

@@ -2,87 +2,200 @@
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import Image from 'next/image' import rehypeRaw from 'rehype-raw'
import Link from 'next/link' import { OptimizedImage } from './OptimizedImage'
import { CodeBlock } from './code-block' import { CodeBlock } from './code-block'
import Link from 'next/link'
interface MarkdownRendererProps { interface MarkdownRendererProps {
content: string content: string
className?: string
} }
export default function MarkdownRenderer({ content }: MarkdownRendererProps) { export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
return ( return (
<ReactMarkdown <div className={`prose prose-invert prose-zinc max-w-none ${className}`}>
remarkPlugins={[remarkGfm]} <ReactMarkdown
components={{ remarkPlugins={[remarkGfm]}
h1: ({ children }) => { rehypePlugins={[rehypeRaw]}
const text = String(children) components={{
const id = text img: ({ node, src, alt, title, ...props }) => {
.toLowerCase() if (!src || typeof src !== 'string') return null
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h1 id={id}>{children}</h1>
},
h2: ({ children }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h2 id={id}>{children}</h2>
},
h3: ({ children }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return <h3 id={id}>{children}</h3>
},
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '')
if (!inline && match) {
return <CodeBlock code={String(children).replace(/\n$/, '')} language={match[1]} />
}
return <code {...props}>{children}</code>
},
img: ({ src, alt }) => {
if (!src || typeof src !== 'string') return null
const isExternal = src.startsWith('http://') || src.startsWith('https://')
if (isExternal) { const isExternal = src.startsWith('http://') || src.startsWith('https://')
return <img src={src} alt={alt || ''} className="w-full h-auto" />
}
return ( if (isExternal) {
<div className="relative w-full h-auto"> return (
<Image <img
src={src} src={src}
alt={alt || ''} alt={alt || ''}
width={800} title={title}
height={600} className="rounded-lg border border-zinc-800"
style={{ width: '100%', height: 'auto' }} {...props}
/> />
</div> )
) }
}, // Ensure absolute path for Next Image
a: ({ href, children }) => { const absoluteSrc = src.startsWith('/') ? src : `/${src}`
if (!href) return <>{children}</>
const isExternal = href.startsWith('http://') || href.startsWith('https://') const titleStr = typeof title === 'string' ? title : ''
const [altText, caption] = titleStr?.includes('|')
? titleStr.split('|').map(s => s.trim())
: [alt, undefined]
const url = new URL(absoluteSrc, 'http://localhost')
const width = url.searchParams.get('w') ? parseInt(url.searchParams.get('w')!) : 800
const height = url.searchParams.get('h') ? parseInt(url.searchParams.get('h')!) : 600
const cleanSrc = absoluteSrc.split('?')[0]
if (isExternal) {
return ( return (
<a href={href} target="_blank" rel="noopener noreferrer"> <OptimizedImage
{children} src={cleanSrc}
</a> alt={altText || alt || ''}
caption={caption}
width={width}
height={height}
/>
) )
} },
code: ({ node, className, children, ...props }) => {
const inline = !className && typeof children === 'string' && !children.includes('\n')
const match = /language-(\w+)/.exec(className || '')
const language = match ? match[1] : ''
return <Link href={href}>{children}</Link> if (inline) {
}, return (
}} <code
> className="rounded bg-zinc-900 px-1.5 py-0.5 text-sm text-emerald-400"
{content} {...props}
</ReactMarkdown> >
{children}
</code>
)
}
return <CodeBlock code={String(children).replace(/\n$/, '')} language={language} />
},
a: ({ node, href, children, ...props }) => {
if (!href) return <a {...props}>{children}</a>
const isExternal = href.startsWith('http://') || href.startsWith('https://')
const isAnchor = href.startsWith('#')
if (isExternal) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-emerald-400 hover:text-emerald-300 inline-flex items-center gap-1"
{...props}
>
{children}
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
)
}
if (isAnchor) {
return (
<a href={href} className="text-emerald-400 hover:text-emerald-300" {...props}>
{children}
</a>
)
}
return (
<Link href={href} className="text-emerald-400 hover:text-emerald-300" {...props}>
{children}
</Link>
)
},
h1: ({ node, children, ...props }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return (
<h1 id={id} className="text-3xl font-bold text-zinc-100 mt-8 mb-4" {...props}>
{children}
</h1>
)
},
h2: ({ node, children, ...props }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return (
<h2 id={id} className="text-2xl font-bold text-zinc-100 mt-6 mb-3" {...props}>
{children}
</h2>
)
},
h3: ({ node, children, ...props }) => {
const text = String(children)
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
return (
<h3 id={id} className="text-xl font-bold text-zinc-100 mt-4 mb-2" {...props}>
{children}
</h3>
)
},
ul: ({ node, children, ...props }) => (
<ul className="list-disc list-inside space-y-2 text-zinc-300" {...props}>
{children}
</ul>
),
ol: ({ node, children, ...props }) => (
<ol className="list-decimal list-inside space-y-2 text-zinc-300" {...props}>
{children}
</ol>
),
blockquote: ({ node, children, ...props }) => (
<blockquote
className="border-l-4 border-emerald-500 pl-4 italic text-zinc-400"
{...props}
>
{children}
</blockquote>
),
table: ({ node, children, ...props }) => (
<div className="overflow-x-auto my-6">
<table className="min-w-full border border-zinc-800" {...props}>
{children}
</table>
</div>
),
th: ({ node, children, ...props }) => (
<th
className="bg-zinc-900 px-4 py-2 text-left font-bold text-zinc-100 border border-zinc-800"
{...props}
>
{children}
</th>
),
td: ({ node, children, ...props }) => (
<td className="px-4 py-2 text-zinc-300 border border-zinc-800" {...props}>
{children}
</td>
),
}}
>
{content}
</ReactMarkdown>
</div>
) )
} }

View File

@@ -1,18 +1,16 @@
import Link from 'next/link'; import Link from 'next/link'
import { getPopularTags } from '@/lib/tags'; import { getPopularTags } from '@/lib/tags'
import { TagBadge } from './tag-badge'; import { TagBadge } from './tag-badge'
export async function PopularTags({ limit = 5 }: { limit?: number }) { export async function PopularTags({ limit = 5 }: { limit?: number }) {
const tags = await getPopularTags(limit); const tags = await getPopularTags(limit)
if (tags.length === 0) return null; if (tags.length === 0) return null
return ( return (
<div className="border-2 border-slate-700 bg-slate-900 p-6"> <div className="border-2 border-slate-700 bg-slate-900 p-6">
<div className="border-b-2 border-slate-700 pb-3 mb-4"> <div className="border-b-2 border-slate-700 pb-3 mb-4">
<h3 className="font-mono text-sm font-bold uppercase text-cyan-400"> <h3 className="font-mono text-sm font-bold uppercase text-cyan-400">POPULAR TAGS</h3>
POPULAR TAGS
</h3>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{tags.map((tag, index) => ( {tags.map((tag, index) => (
@@ -22,9 +20,7 @@ export async function PopularTags({ limit = 5 }: { limit?: number }) {
className="flex items-center justify-between group p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition" className="flex items-center justify-between group p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition"
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<span className="font-mono text-xs text-zinc-500"> <span className="font-mono text-xs text-zinc-500">[{index + 1}]</span>
[{index + 1}]
</span>
<span className="font-mono text-xs uppercase group-hover:text-cyan-400 transition"> <span className="font-mono text-xs uppercase group-hover:text-cyan-400 transition">
#{tag.name} #{tag.name}
</span> </span>
@@ -40,5 +36,5 @@ export async function PopularTags({ limit = 5 }: { limit?: number }) {
&gt; VIEW ALL TAGS &gt; VIEW ALL TAGS
</Link> </Link>
</div> </div>
); )
} }

View File

@@ -1,6 +1,6 @@
interface TagBadgeProps { interface TagBadgeProps {
count: number; count: number
className?: string; className?: string
} }
export function TagBadge({ count, className = '' }: TagBadgeProps) { export function TagBadge({ count, className = '' }: TagBadgeProps) {
@@ -16,5 +16,5 @@ export function TagBadge({ count, className = '' }: TagBadgeProps) {
> >
{count} {count}
</span> </span>
); )
} }

View File

@@ -1,8 +1,8 @@
import Link from 'next/link'; import Link from 'next/link'
import { TagInfo } from '@/lib/tags'; import { TagInfo } from '@/lib/tags'
interface TagCloudProps { interface TagCloudProps {
tags: Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>; tags: Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>
} }
export function TagCloud({ tags }: TagCloudProps) { export function TagCloud({ tags }: TagCloudProps) {
@@ -11,7 +11,7 @@ export function TagCloud({ tags }: TagCloudProps) {
md: 'text-sm', md: 'text-sm',
lg: 'text-base font-bold', lg: 'text-base font-bold',
xl: 'text-lg font-bold', xl: 'text-lg font-bold',
}; }
return ( return (
<div className="flex flex-wrap gap-4 items-baseline"> <div className="flex flex-wrap gap-4 items-baseline">
@@ -32,5 +32,5 @@ export function TagCloud({ tags }: TagCloudProps) {
</Link> </Link>
))} ))}
</div> </div>
); )
} }

View File

@@ -1,24 +1,27 @@
import Link from 'next/link'; import Link from 'next/link'
import { slugifyTag } from '@/lib/tags'; import { slugifyTag } from '@/lib/tags'
interface TagListProps { interface TagListProps {
tags: (string | undefined)[]; tags: (string | undefined)[]
variant?: 'default' | 'minimal' | 'colored'; variant?: 'default' | 'minimal' | 'colored'
className?: string; className?: string
} }
export function TagList({ tags, variant = 'default', className = '' }: TagListProps) { export function TagList({ tags, variant = 'default', className = '' }: TagListProps) {
const validTags = tags.filter(Boolean) as string[]; const validTags = tags.filter(Boolean) as string[]
if (validTags.length === 0) return null; if (validTags.length === 0) return null
const baseClasses = 'inline-flex items-center font-mono text-xs uppercase border transition-colors'; const baseClasses =
'inline-flex items-center font-mono text-xs uppercase border transition-colors'
const variants = { const variants = {
default: 'px-3 py-1 bg-zinc-900 border-slate-700 text-zinc-400 hover:border-cyan-400 hover:text-cyan-400', default:
'px-3 py-1 bg-zinc-900 border-slate-700 text-zinc-400 hover:border-cyan-400 hover:text-cyan-400',
minimal: 'px-2 py-0.5 border-transparent text-zinc-500 hover:text-cyan-400', minimal: 'px-2 py-0.5 border-transparent text-zinc-500 hover:text-cyan-400',
colored: 'px-3 py-1 bg-cyan-900 border-cyan-700 text-cyan-300 hover:bg-cyan-800 hover:border-cyan-600', colored:
}; 'px-3 py-1 bg-cyan-900 border-cyan-700 text-cyan-300 hover:bg-cyan-800 hover:border-cyan-600',
}
return ( return (
<div className={`flex flex-wrap gap-2 ${className}`}> <div className={`flex flex-wrap gap-2 ${className}`}>
@@ -33,5 +36,5 @@ export function TagList({ tags, variant = 'default', className = '' }: TagListPr
</Link> </Link>
))} ))}
</div> </div>
); )
} }

View File

@@ -0,0 +1,38 @@
import Image from 'next/image'
interface IconWrapperProps {
name: string
alt?: string
size?: number
className?: string
}
export function IconWrapper({ name, alt, size = 32, className = '' }: IconWrapperProps) {
const iconPath = `/icons/${name}.png`
return <Image src={iconPath} alt={alt || name} width={size} height={size} className={className} />
}
export function EmailIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Email" size={size} className={className} />
}
export function TerminalIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Terminal" size={size} className={className} />
}
export function FolderIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Folder" size={size} className={className} />
}
export function DocumentIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Document" size={size} className={className} />
}
export function SettingsIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Settings" size={size} className={className} />
}
export function NetworkIcon({ size = 32, className = '' }: { size?: number; className?: string }) {
return <IconWrapper name="Network" size={size} className={className} />
}

View File

@@ -0,0 +1,74 @@
export {
IconWrapper,
EmailIcon,
TerminalIcon,
FolderIcon,
DocumentIcon,
SettingsIcon,
NetworkIcon,
} from './IconWrapper'
export function HomeIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
)
}
export function SearchIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
)
}
export function TagIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
)
}
export function CalendarIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
)
}
export function ClockIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)
}

View File

@@ -36,6 +36,10 @@ async function fetchUser(id: number): Promise<User> {
} }
``` ```
### Use of coolers
- ![Use of coolers](./cooler.jpg?w=400&h=300)
## Concluzie ## Concluzie
Subdirectoarele funcționează perfect pentru organizarea conținutului! Subdirectoarele funcționează perfect pentru organizarea conținutului!

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

11
fix.js Normal file
View File

@@ -0,0 +1,11 @@
const fs = require('fs')
let content = fs.readFileSync('lib/remark-copy-images.ts', 'utf8')
const lines = content.split('\n')
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('replace')) {
console.log(`Line ${i + 1}:`, JSON.stringify(lines[i]))
lines[i] = lines[i].replace(/replace\(\/\\/g / g, 'replace(/\\/g')
console.log(`Fixed:`, JSON.stringify(lines[i]))
}
}
fs.writeFileSync('lib/remark-copy-images.ts', lines.join('\n'))

85
lib/image-utils.ts Normal file
View File

@@ -0,0 +1,85 @@
import { promises as fs } from 'fs'
import path from 'path'
export async function imageExists(imagePath: string): Promise<boolean> {
try {
const fullPath = path.join(process.cwd(), 'public', imagePath)
await fs.access(fullPath)
return true
} catch {
return false
}
}
export async function getImageDimensions(
imagePath: string
): Promise<{ width: number; height: number } | null> {
try {
const fullPath = path.join(process.cwd(), 'public', imagePath)
const buffer = await fs.readFile(fullPath)
if (imagePath.endsWith('.png')) {
const width = buffer.readUInt32BE(16)
const height = buffer.readUInt32BE(20)
return { width, height }
}
if (imagePath.endsWith('.jpg') || imagePath.endsWith('.jpeg')) {
let offset = 2
while (offset < buffer.length) {
if (buffer[offset] !== 0xff) break
const marker = buffer[offset + 1]
if (marker === 0xc0 || marker === 0xc2) {
const height = buffer.readUInt16BE(offset + 5)
const width = buffer.readUInt16BE(offset + 7)
return { width, height }
}
offset += 2 + buffer.readUInt16BE(offset + 2)
}
}
return null
} catch {
return null
}
}
export function getOptimizedImageUrl(
src: string,
width?: number,
height?: number,
quality: number = 75
): string {
const params = new URLSearchParams()
if (width) params.set('w', width.toString())
if (height) params.set('h', height.toString())
params.set('q', quality.toString())
const queryString = params.toString()
return queryString ? `${src}?${queryString}` : src
}
export async function getImageWithPlaceholder(
imagePath: string
): Promise<{ src: string; width: number; height: number; placeholder?: string }> {
const dimensions = await getImageDimensions(imagePath)
if (!dimensions) {
return {
src: imagePath,
width: 800,
height: 600,
}
}
const placeholder = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${dimensions.width}' height='${dimensions.height}'%3E%3Crect width='${dimensions.width}' height='${dimensions.height}' fill='%2318181b'/%3E%3C/svg%3E`
return {
src: imagePath,
...dimensions,
placeholder,
}
}

View File

@@ -1,8 +1,11 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import matter from 'gray-matter' import matter from 'gray-matter'
import { remark } from 'remark'
import remarkGfm from 'remark-gfm'
import { FrontMatter, Post } from './types/frontmatter' import { FrontMatter, Post } from './types/frontmatter'
import { generateExcerpt } from './utils' import { generateExcerpt } from './utils'
import { remarkCopyImages } from './remark-copy-images'
const POSTS_PATH = path.join(process.cwd(), 'content', 'blog') const POSTS_PATH = path.join(process.cwd(), 'content', 'blog')
@@ -52,7 +55,7 @@ export function validateFrontmatter(data: any): FrontMatter {
} }
} }
export function getPostBySlug(slug: string | string[]): Post | null { export async function getPostBySlug(slug: string | string[]): Promise<Post | null> {
const slugArray = Array.isArray(slug) ? slug : slug.split('/') const slugArray = Array.isArray(slug) ? slug : slug.split('/')
const sanitized = slugArray.map(s => sanitizePath(s)) const sanitized = slugArray.map(s => sanitizePath(s))
const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md' const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md'
@@ -65,19 +68,30 @@ export function getPostBySlug(slug: string | string[]): Post | null {
const { data, content } = matter(fileContents) const { data, content } = matter(fileContents)
const frontmatter = validateFrontmatter(data) const frontmatter = validateFrontmatter(data)
const processed = await remark()
.use(remarkGfm)
.use(remarkCopyImages, {
contentDir: 'content/blog',
publicDir: 'public/blog',
currentSlug: sanitized.join('/'),
})
.process(content)
const processedContent = processed.toString()
return { return {
slug: sanitized.join('/'), slug: sanitized.join('/'),
frontmatter, frontmatter,
content, content: processedContent,
readingTime: calculateReadingTime(content), readingTime: calculateReadingTime(processedContent),
excerpt: generateExcerpt(content), excerpt: generateExcerpt(processedContent),
} }
} }
export function getAllPosts(includeContent = false): Post[] { export async function getAllPosts(includeContent = false): Promise<Post[]> {
const posts: Post[] = [] const posts: Post[] = []
function walkDir(dir: string, prefix = ''): void { async function walkDir(dir: string, prefix = ''): Promise<void> {
const files = fs.readdirSync(dir) const files = fs.readdirSync(dir)
for (const file of files) { for (const file of files) {
@@ -85,11 +99,11 @@ export function getAllPosts(includeContent = false): Post[] {
const stat = fs.statSync(filePath) const stat = fs.statSync(filePath)
if (stat.isDirectory()) { if (stat.isDirectory()) {
walkDir(filePath, prefix ? `${prefix}/${file}` : file) await walkDir(filePath, prefix ? `${prefix}/${file}` : file)
} else if (file.endsWith('.md')) { } else if (file.endsWith('.md')) {
const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, '') const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, '')
try { try {
const post = getPostBySlug(slug.split('/')) const post = await getPostBySlug(slug.split('/'))
if (post && !post.frontmatter.draft) { if (post && !post.frontmatter.draft) {
posts.push(includeContent ? post : { ...post, content: '' }) posts.push(includeContent ? post : { ...post, content: '' })
} }
@@ -101,7 +115,7 @@ export function getAllPosts(includeContent = false): Post[] {
} }
if (fs.existsSync(POSTS_PATH)) { if (fs.existsSync(POSTS_PATH)) {
walkDir(POSTS_PATH) await walkDir(POSTS_PATH)
} }
return posts.sort( return posts.sort(
@@ -110,10 +124,10 @@ export function getAllPosts(includeContent = false): Post[] {
} }
export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<Post[]> { export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<Post[]> {
const currentPost = getPostBySlug(currentSlug) const currentPost = await getPostBySlug(currentSlug)
if (!currentPost) return [] if (!currentPost) return []
const allPosts = getAllPosts(false) const allPosts = await getAllPosts(false)
const { category, tags } = currentPost.frontmatter const { category, tags } = currentPost.frontmatter
const scored = allPosts const scored = allPosts

146
lib/remark-copy-images.ts Normal file
View File

@@ -0,0 +1,146 @@
import { visit } from 'unist-util-visit'
import fs from 'fs/promises'
import path from 'path'
import { Node } from 'unist'
interface ImageNode extends Node {
type: 'image'
url: string
alt?: string
title?: string
}
interface Options {
contentDir: string
publicDir: string
currentSlug: string
}
function isRelativePath(url: string): boolean {
// Matches: ./, ../, or bare filenames without protocol/absolute path
return (
url.startsWith('./') || url.startsWith('../') || (!url.startsWith('/') && !url.includes('://'))
)
}
function stripQueryParams(url: string): string {
return url.split('?')[0]
}
// In-memory cache to prevent duplicate copies across parallel compilations
const copiedFiles = new Set<string>()
async function copyAndRewritePath(node: ImageNode, options: Options): Promise<void> {
const { contentDir, publicDir, currentSlug } = options
const urlWithoutParams = stripQueryParams(node.url)
const slugParts = currentSlug.split('/')
const contentPostDir = path.join(process.cwd(), contentDir, ...slugParts.slice(0, -1))
const sourcePath = path.resolve(contentPostDir, urlWithoutParams)
if (sourcePath.includes('..') && !sourcePath.startsWith(path.join(process.cwd(), contentDir))) {
throw new Error(`Invalid image path: ${node.url} (path traversal detected)`)
}
const relativeToContent = path.relative(path.join(process.cwd(), contentDir), sourcePath)
const destPath = path.join(process.cwd(), publicDir, relativeToContent)
try {
await fs.access(sourcePath)
} catch {
throw new Error(
`Image not found: ${sourcePath}\nReferenced in: ${currentSlug}\nURL: ${node.url}`
)
}
const destDir = path.dirname(destPath)
await fs.mkdir(destDir, { recursive: true })
// Deduplication: check cache first
const cacheKey = `${sourcePath}:${destPath}`
if (copiedFiles.has(cacheKey)) {
// Already copied, just rewrite URL
const publicUrl =
'/' + path.relative(path.join(process.cwd(), 'public'), destPath).replace(/\\/g, '/')
const queryParams = node.url.includes('?') ? '?' + node.url.split('?')[1] : ''
node.url = publicUrl + queryParams
return
}
// Check if destination exists with matching size
try {
const [sourceStat, destStat] = await Promise.all([
fs.stat(sourcePath),
fs.stat(destPath).catch(() => null),
])
if (destStat && sourceStat.size === destStat.size) {
// File already exists and matches, skip copy
copiedFiles.add(cacheKey)
const publicUrl =
'/' + path.relative(path.join(process.cwd(), 'public'), destPath).replace(/\\/g, '/')
const queryParams = node.url.includes('?') ? '?' + node.url.split('?')[1] : ''
node.url = publicUrl + queryParams
return
}
} catch (error) {
// Stat failed, proceed with copy
}
// Attempt copy with EBUSY retry logic
try {
await fs.copyFile(sourcePath, destPath)
copiedFiles.add(cacheKey)
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException
if (err.code === 'EBUSY') {
// Race condition: another process is copying this file
// Wait briefly and check if file now exists
await new Promise(resolve => setTimeout(resolve, 100))
try {
await fs.access(destPath)
// File exists now, verify integrity
const [sourceStat, destStat] = await Promise.all([fs.stat(sourcePath), fs.stat(destPath)])
if (sourceStat.size === destStat.size) {
// Successfully copied by another process
copiedFiles.add(cacheKey)
} else {
// File corrupted, retry once
await fs.copyFile(sourcePath, destPath)
copiedFiles.add(cacheKey)
}
} catch {
// File still doesn't exist, retry copy
await fs.copyFile(sourcePath, destPath)
copiedFiles.add(cacheKey)
}
} else {
// Unknown error, rethrow
throw error
}
}
const publicUrl =
'/' + path.relative(path.join(process.cwd(), 'public'), destPath).replace(/\\/g, '/')
const queryParams = node.url.includes('?') ? '?' + node.url.split('?')[1] : ''
node.url = publicUrl + queryParams
}
export function remarkCopyImages(options: Options) {
return async (tree: Node) => {
const promises: Promise<void>[] = []
visit(tree, 'image', (node: Node) => {
const imageNode = node as ImageNode
if (isRelativePath(imageNode.url)) {
promises.push(copyAndRewritePath(imageNode, options))
}
})
await Promise.all(promises)
}
}

View File

@@ -1,15 +1,15 @@
import { getAllPosts } from './markdown'; import { getAllPosts } from './markdown'
import type { Post } from './types/frontmatter'; import type { Post } from './types/frontmatter'
export interface TagInfo { export interface TagInfo {
name: string; name: string
slug: string; slug: string
count: number; count: number
} }
export interface TagWithPosts { export interface TagWithPosts {
tag: TagInfo; tag: TagInfo
posts: Post[]; posts: Post[]
} }
export function slugifyTag(tag: string): string { export function slugifyTag(tag: string): string {
@@ -22,110 +22,108 @@ export function slugifyTag(tag: string): string {
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '') .replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-') .replace(/-+/g, '-')
.replace(/^-|-$/g, ''); .replace(/^-|-$/g, '')
} }
export async function getAllTags(): Promise<TagInfo[]> { export async function getAllTags(): Promise<TagInfo[]> {
const posts = getAllPosts(); const posts = await getAllPosts()
const tagMap = new Map<string, number>(); const tagMap = new Map<string, number>()
posts.forEach(post => { posts.forEach(post => {
const tags = post.frontmatter.tags?.filter(Boolean) || []; const tags = post.frontmatter.tags?.filter(Boolean) || []
tags.forEach(tag => { tags.forEach(tag => {
const count = tagMap.get(tag) || 0; const count = tagMap.get(tag) || 0
tagMap.set(tag, count + 1); tagMap.set(tag, count + 1)
}); })
}); })
return Array.from(tagMap.entries()) return Array.from(tagMap.entries())
.map(([name, count]) => ({ .map(([name, count]) => ({
name, name,
slug: slugifyTag(name), slug: slugifyTag(name),
count count,
})) }))
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count)
} }
export async function getPostsByTag(tagSlug: string): Promise<Post[]> { export async function getPostsByTag(tagSlug: string): Promise<Post[]> {
const posts = getAllPosts(); const posts = await getAllPosts()
return posts.filter(post => { return posts.filter(post => {
const tags = post.frontmatter.tags?.filter(Boolean) || []; const tags = post.frontmatter.tags?.filter(Boolean) || []
return tags.some(tag => slugifyTag(tag) === tagSlug); return tags.some(tag => slugifyTag(tag) === tagSlug)
}); })
} }
export async function getTagInfo(tagSlug: string): Promise<TagInfo | null> { export async function getTagInfo(tagSlug: string): Promise<TagInfo | null> {
const allTags = await getAllTags(); const allTags = await getAllTags()
return allTags.find(tag => tag.slug === tagSlug) || null; return allTags.find(tag => tag.slug === tagSlug) || null
} }
export async function getPopularTags(limit = 10): Promise<TagInfo[]> { export async function getPopularTags(limit = 10): Promise<TagInfo[]> {
const allTags = await getAllTags(); const allTags = await getAllTags()
return allTags.slice(0, limit); return allTags.slice(0, limit)
} }
export async function getRelatedTags(tagSlug: string, limit = 5): Promise<TagInfo[]> { export async function getRelatedTags(tagSlug: string, limit = 5): Promise<TagInfo[]> {
const posts = await getPostsByTag(tagSlug); const posts = await getPostsByTag(tagSlug)
const relatedTagMap = new Map<string, number>(); const relatedTagMap = new Map<string, number>()
posts.forEach(post => { posts.forEach(post => {
const tags = post.frontmatter.tags?.filter(Boolean) || []; const tags = post.frontmatter.tags?.filter(Boolean) || []
tags.forEach(tag => { tags.forEach(tag => {
const slug = slugifyTag(tag); const slug = slugifyTag(tag)
if (slug !== tagSlug) { if (slug !== tagSlug) {
const count = relatedTagMap.get(tag) || 0; const count = relatedTagMap.get(tag) || 0
relatedTagMap.set(tag, count + 1); relatedTagMap.set(tag, count + 1)
} }
}); })
}); })
return Array.from(relatedTagMap.entries()) return Array.from(relatedTagMap.entries())
.map(([name, count]) => ({ .map(([name, count]) => ({
name, name,
slug: slugifyTag(name), slug: slugifyTag(name),
count count,
})) }))
.sort((a, b) => b.count - a.count) .sort((a, b) => b.count - a.count)
.slice(0, limit); .slice(0, limit)
} }
export function validateTags(tags: any): string[] { export function validateTags(tags: any): string[] {
if (!tags) return []; if (!tags) return []
if (!Array.isArray(tags)) { if (!Array.isArray(tags)) {
console.warn('Tags should be an array'); console.warn('Tags should be an array')
return []; return []
} }
const validTags = tags const validTags = tags.filter(tag => tag && typeof tag === 'string').slice(0, 3)
.filter(tag => tag && typeof tag === 'string')
.slice(0, 3);
if (tags.length > 3) { if (tags.length > 3) {
console.warn(`Too many tags provided (${tags.length}). Limited to first 3.`); console.warn(`Too many tags provided (${tags.length}). Limited to first 3.`)
} }
return validTags; return validTags
} }
export async function getTagCloud(): Promise<Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>> { export async function getTagCloud(): Promise<Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>> {
const tags = await getAllTags(); const tags = await getAllTags()
if (tags.length === 0) return []; if (tags.length === 0) return []
const maxCount = Math.max(...tags.map(t => t.count)); const maxCount = Math.max(...tags.map(t => t.count))
const minCount = Math.min(...tags.map(t => t.count)); const minCount = Math.min(...tags.map(t => t.count))
const range = maxCount - minCount || 1; const range = maxCount - minCount || 1
return tags.map(tag => { return tags.map(tag => {
const normalized = (tag.count - minCount) / range; const normalized = (tag.count - minCount) / range
let size: 'sm' | 'md' | 'lg' | 'xl'; let size: 'sm' | 'md' | 'lg' | 'xl'
if (normalized < 0.25) size = 'sm'; if (normalized < 0.25) size = 'sm'
else if (normalized < 0.5) size = 'md'; else if (normalized < 0.5) size = 'md'
else if (normalized < 0.75) size = 'lg'; else if (normalized < 0.75) size = 'lg'
else size = 'xl'; else size = 'xl'
return { ...tag, size }; return { ...tag, size }
}); })
} }

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

3
package-lock.json generated
View File

@@ -28,7 +28,8 @@
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"unist-util-visit": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",

View File

@@ -40,7 +40,8 @@
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"unist-util-visit": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",