diff --git a/.gitignore b/.gitignore index 6e806ff..f2286f2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,9 @@ yarn-error.log* .vercel *.tsbuildinfo next-env.d.ts + +# Build artifacts (copied images) +public/blog/**/*.jpg +public/blog/**/*.png +public/blog/**/*.webp +public/blog/**/*.gif diff --git a/Dockerfile.nextjs b/Dockerfile.nextjs index cf2c814..7881827 100644 --- a/Dockerfile.nextjs +++ b/Dockerfile.nextjs @@ -5,7 +5,7 @@ # ============================================ # Stage 1: Dependencies Installation # ============================================ -FROM node:20-alpine AS deps +FROM node:22-alpine AS deps # Install libc6-compat for better compatibility RUN apk add --no-cache libc6-compat @@ -24,7 +24,7 @@ RUN npm ci # ============================================ # Stage 2: Build Next.js Application # ============================================ -FROM node:20-alpine AS builder +FROM node:22-alpine AS builder WORKDIR /app @@ -57,7 +57,7 @@ RUN npm run build # ============================================ # Stage 3: Production Runtime # ============================================ -FROM node:20-alpine AS runner +FROM node:22-alpine AS runner # Install curl for health checks RUN apk add --no-cache curl diff --git a/app/@breadcrumbs/blog/[...slug]/page.tsx b/app/@breadcrumbs/blog/[...slug]/page.tsx index 3b700e9..bc6f404 100644 --- a/app/@breadcrumbs/blog/[...slug]/page.tsx +++ b/app/@breadcrumbs/blog/[...slug]/page.tsx @@ -24,7 +24,7 @@ export default async function BlogPostBreadcrumb({ }) { const { slug } = await params const slugPath = slug.join('/') - const post = getPostBySlug(slugPath) + const post = await getPostBySlug(slugPath) const items: BreadcrumbItem[] = [ { diff --git a/app/blog/[...slug]/page.tsx b/app/blog/[...slug]/page.tsx index aa30749..c97fac1 100644 --- a/app/blog/[...slug]/page.tsx +++ b/app/blog/[...slug]/page.tsx @@ -20,7 +20,7 @@ export async function generateMetadata({ }): Promise { const { slug } = await params const slugPath = slug.join('/') - const post = getPostBySlug(slugPath) + const post = await getPostBySlug(slugPath) if (!post) { return { title: 'Articol negăsit' } @@ -68,7 +68,7 @@ function extractHeadings(content: string) { export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) { const { slug } = await params const slugPath = slug.join('/') - const post = getPostBySlug(slugPath) + const post = await getPostBySlug(slugPath) if (!post) { notFound() diff --git a/app/tags/[tag]/not-found.tsx b/app/tags/[tag]/not-found.tsx index 4b9e6c6..8714e8e 100644 --- a/app/tags/[tag]/not-found.tsx +++ b/app/tags/[tag]/not-found.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link'; +import Link from 'next/link' export default function TagNotFound() { return ( @@ -36,5 +36,5 @@ export default function TagNotFound() { - ); + ) } diff --git a/app/tags/[tag]/page.tsx b/app/tags/[tag]/page.tsx index 2d970d9..23fcbd8 100644 --- a/app/tags/[tag]/page.tsx +++ b/app/tags/[tag]/page.tsx @@ -1,30 +1,25 @@ -import { Metadata } from 'next'; -import { notFound } from 'next/navigation'; -import Link from 'next/link'; -import { - getAllTags, - getPostsByTag, - getTagInfo, - getRelatedTags -} from '@/lib/tags'; -import { TagList } from '@/components/blog/tag-list'; -import { formatDate } from '@/lib/utils'; +import { Metadata } from 'next' +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { getAllTags, getPostsByTag, getTagInfo, getRelatedTags } from '@/lib/tags' +import { TagList } from '@/components/blog/tag-list' +import { formatDate } from '@/lib/utils' export async function generateStaticParams() { - const tags = await getAllTags(); - return tags.map(tag => ({ tag: tag.slug })); + const tags = await getAllTags() + return tags.map(tag => ({ tag: tag.slug })) } export async function generateMetadata({ params, }: { - params: Promise<{ tag: string }>; + params: Promise<{ tag: string }> }): Promise { - const { tag } = await params; - const tagInfo = await getTagInfo(tag); + const { tag } = await params + const tagInfo = await getTagInfo(tag) if (!tagInfo) { - return { title: 'Tag negăsit' }; + return { title: 'Tag negăsit' } } return { @@ -34,7 +29,7 @@ export async function generateMetadata({ title: `Tag: ${tagInfo.name}`, description: `Explorează ${tagInfo.count} articole despre ${tagInfo.name}`, }, - }; + } } function PostCard({ post }: { post: any }) { @@ -49,47 +44,34 @@ function PostCard({ post }: { post: any }) { )}
- + > {post.readingTime} min

- + {post.frontmatter.title}

-

- {post.frontmatter.description} -

+

{post.frontmatter.description}

- {post.frontmatter.tags && ( - - )} + {post.frontmatter.tags && } - ); + ) } -export default async function TagPage({ - params, -}: { - params: Promise<{ tag: string }>; -}) { - const { tag } = await params; - const tagInfo = await getTagInfo(tag); +export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) { + const { tag } = await params + const tagInfo = await getTagInfo(tag) if (!tagInfo) { - notFound(); + notFound() } - const posts = await getPostsByTag(tag); - const relatedTags = await getRelatedTags(tag); + const posts = await getPostsByTag(tag) + const relatedTags = await getRelatedTags(tag) return (
@@ -122,9 +104,7 @@ export default async function TagPage({
{posts.length === 0 ? (
-

- > NO DOCUMENTS FOUND -

+

> NO DOCUMENTS FOUND

- - #{tag.name} - - - [{tag.count}] - + #{tag.name} + [{tag.count}] ))}
@@ -170,9 +146,7 @@ export default async function TagPage({
-

- QUICK NAV -

+

QUICK NAV

- ); + ) } diff --git a/app/tags/page.tsx b/app/tags/page.tsx index 7a06d21..3b8e76b 100644 --- a/app/tags/page.tsx +++ b/app/tags/page.tsx @@ -1,17 +1,17 @@ -import { Metadata } from 'next'; -import Link from 'next/link'; -import { getAllTags, getTagCloud } from '@/lib/tags'; -import { TagCloud } from '@/components/blog/tag-cloud'; -import { TagBadge } from '@/components/blog/tag-badge'; +import { Metadata } from 'next' +import Link from 'next/link' +import { getAllTags, getTagCloud } from '@/lib/tags' +import { TagCloud } from '@/components/blog/tag-cloud' +import { TagBadge } from '@/components/blog/tag-badge' export const metadata: Metadata = { title: 'Tag-uri', description: 'Explorează articolele după tag-uri', -}; +} export default async function TagsPage() { - const allTags = await getAllTags(); - const tagCloud = await getTagCloud(); + const allTags = await getAllTags() + const tagCloud = await getTagCloud() if (allTags.length === 0) { return ( @@ -21,9 +21,7 @@ export default async function TagsPage() {

TAG DATABASE

-

- > NO TAGS AVAILABLE -

+

> NO TAGS AVAILABLE

- ); + ) } - const groupedTags = allTags.reduce((acc, tag) => { - const firstLetter = tag.name[0].toUpperCase(); - if (!acc[firstLetter]) { - acc[firstLetter] = []; - } - acc[firstLetter].push(tag); - return acc; - }, {} as Record); + const groupedTags = allTags.reduce( + (acc, tag) => { + const firstLetter = tag.name[0].toUpperCase() + if (!acc[firstLetter]) { + acc[firstLetter] = [] + } + acc[firstLetter].push(tag) + return acc + }, + {} as Record + ) - const sortedLetters = Object.keys(groupedTags).sort(); + const sortedLetters = Object.keys(groupedTags).sort() return (
@@ -57,9 +58,7 @@ export default async function TagsPage() {

TAG REGISTRY

-

- > TOTAL TAGS: {allTags.length} -

+

> TOTAL TAGS: {allTags.length}

@@ -109,38 +108,28 @@ export default async function TagsPage() {

DOCUMENT STATISTICS

-

- TAG METRICS -

+

TAG METRICS

-
- {allTags.length} -
-
- TOTAL TAGS -
+
{allTags.length}
+
TOTAL TAGS
{Math.max(...allTags.map(t => t.count))}
-
- MAX POSTS/TAG -
+
MAX POSTS/TAG
{Math.round(allTags.reduce((sum, t) => sum + t.count, 0) / allTags.length)}
-
- AVG POSTS/TAG -
+
AVG POSTS/TAG
- ); + ) } diff --git a/components/blog/ImageGallery.tsx b/components/blog/ImageGallery.tsx new file mode 100644 index 0000000..6061d95 --- /dev/null +++ b/components/blog/ImageGallery.tsx @@ -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(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 ( + <> +
+ {images.map((image, index) => ( + + ))} +
+ + {selectedImage && ( +
setSelectedImage(null)} + > + +
e.stopPropagation()}> + +
+
+ )} + + ) +} diff --git a/components/blog/OptimizedImage.tsx b/components/blog/OptimizedImage.tsx new file mode 100644 index 0000000..6f87d8a --- /dev/null +++ b/components/blog/OptimizedImage.tsx @@ -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 ( +
+

Failed to load image

+ {caption &&

{caption}

} +
+ ) + } + + return ( +
+
+ {alt} 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 && ( +
+
+
+ )} +
+ {caption && ( +
{caption}
+ )} +
+ ) +} diff --git a/components/blog/markdown-renderer.tsx b/components/blog/markdown-renderer.tsx index 0dc1d40..78b3d66 100644 --- a/components/blog/markdown-renderer.tsx +++ b/components/blog/markdown-renderer.tsx @@ -2,87 +2,200 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import Image from 'next/image' -import Link from 'next/link' +import rehypeRaw from 'rehype-raw' +import { OptimizedImage } from './OptimizedImage' import { CodeBlock } from './code-block' +import Link from 'next/link' interface MarkdownRendererProps { content: string + className?: string } -export default function MarkdownRenderer({ content }: MarkdownRendererProps) { +export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) { return ( - { - const text = String(children) - const id = text - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)/g, '') - return

{children}

- }, - h2: ({ children }) => { - const text = String(children) - const id = text - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)/g, '') - return

{children}

- }, - h3: ({ children }) => { - const text = String(children) - const id = text - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)/g, '') - return

{children}

- }, - code: ({ inline, className, children, ...props }: any) => { - const match = /language-(\w+)/.exec(className || '') - if (!inline && match) { - return - } - return {children} - }, - img: ({ src, alt }) => { - if (!src || typeof src !== 'string') return null - const isExternal = src.startsWith('http://') || src.startsWith('https://') +
+ { + if (!src || typeof src !== 'string') return null - if (isExternal) { - return {alt - } + const isExternal = src.startsWith('http://') || src.startsWith('https://') - return ( -
- {alt -
- ) - }, - a: ({ href, children }) => { - if (!href) return <>{children} - const isExternal = href.startsWith('http://') || href.startsWith('https://') + if (isExternal) { + return ( + {alt + ) + } + // Ensure absolute path for Next Image + const absoluteSrc = src.startsWith('/') ? src : `/${src}` + + 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 ( - - {children} - + ) - } + }, + 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 {children} - }, - }} - > - {content} -
+ if (inline) { + return ( + + {children} + + ) + } + + return + }, + a: ({ node, href, children, ...props }) => { + if (!href) return {children} + + const isExternal = href.startsWith('http://') || href.startsWith('https://') + const isAnchor = href.startsWith('#') + + if (isExternal) { + return ( + + {children} + + + + + ) + } + + if (isAnchor) { + return ( + + {children} + + ) + } + + return ( + + {children} + + ) + }, + h1: ({ node, children, ...props }) => { + const text = String(children) + const id = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + return ( +

+ {children} +

+ ) + }, + h2: ({ node, children, ...props }) => { + const text = String(children) + const id = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + return ( +

+ {children} +

+ ) + }, + h3: ({ node, children, ...props }) => { + const text = String(children) + const id = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + return ( +

+ {children} +

+ ) + }, + ul: ({ node, children, ...props }) => ( +
    + {children} +
+ ), + ol: ({ node, children, ...props }) => ( +
    + {children} +
+ ), + blockquote: ({ node, children, ...props }) => ( +
+ {children} +
+ ), + table: ({ node, children, ...props }) => ( +
+ + {children} +
+
+ ), + th: ({ node, children, ...props }) => ( + + {children} + + ), + td: ({ node, children, ...props }) => ( + + {children} + + ), + }} + > + {content} + +
) } diff --git a/components/blog/popular-tags.tsx b/components/blog/popular-tags.tsx index 87f09b2..711ae29 100644 --- a/components/blog/popular-tags.tsx +++ b/components/blog/popular-tags.tsx @@ -1,18 +1,16 @@ -import Link from 'next/link'; -import { getPopularTags } from '@/lib/tags'; -import { TagBadge } from './tag-badge'; +import Link from 'next/link' +import { getPopularTags } from '@/lib/tags' +import { TagBadge } from './tag-badge' 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 (
-

- POPULAR TAGS -

+

POPULAR TAGS

{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" >
- - [{index + 1}] - + [{index + 1}] #{tag.name} @@ -40,5 +36,5 @@ export async function PopularTags({ limit = 5 }: { limit?: number }) { > VIEW ALL TAGS
- ); + ) } diff --git a/components/blog/tag-badge.tsx b/components/blog/tag-badge.tsx index 6f4032d..63d1e44 100644 --- a/components/blog/tag-badge.tsx +++ b/components/blog/tag-badge.tsx @@ -1,6 +1,6 @@ interface TagBadgeProps { - count: number; - className?: string; + count: number + className?: string } export function TagBadge({ count, className = '' }: TagBadgeProps) { @@ -16,5 +16,5 @@ export function TagBadge({ count, className = '' }: TagBadgeProps) { > {count} - ); + ) } diff --git a/components/blog/tag-cloud.tsx b/components/blog/tag-cloud.tsx index 50c0b00..1224d1c 100644 --- a/components/blog/tag-cloud.tsx +++ b/components/blog/tag-cloud.tsx @@ -1,8 +1,8 @@ -import Link from 'next/link'; -import { TagInfo } from '@/lib/tags'; +import Link from 'next/link' +import { TagInfo } from '@/lib/tags' interface TagCloudProps { - tags: Array; + tags: Array } export function TagCloud({ tags }: TagCloudProps) { @@ -11,7 +11,7 @@ export function TagCloud({ tags }: TagCloudProps) { md: 'text-sm', lg: 'text-base font-bold', xl: 'text-lg font-bold', - }; + } return (
@@ -32,5 +32,5 @@ export function TagCloud({ tags }: TagCloudProps) { ))}
- ); + ) } diff --git a/components/blog/tag-list.tsx b/components/blog/tag-list.tsx index 187a391..603aebc 100644 --- a/components/blog/tag-list.tsx +++ b/components/blog/tag-list.tsx @@ -1,24 +1,27 @@ -import Link from 'next/link'; -import { slugifyTag } from '@/lib/tags'; +import Link from 'next/link' +import { slugifyTag } from '@/lib/tags' interface TagListProps { - tags: (string | undefined)[]; - variant?: 'default' | 'minimal' | 'colored'; - className?: string; + tags: (string | undefined)[] + variant?: 'default' | 'minimal' | 'colored' + className?: string } 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 = { - 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', - 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 (
@@ -33,5 +36,5 @@ export function TagList({ tags, variant = 'default', className = '' }: TagListPr ))}
- ); + ) } diff --git a/components/icons/IconWrapper.tsx b/components/icons/IconWrapper.tsx new file mode 100644 index 0000000..8197dd7 --- /dev/null +++ b/components/icons/IconWrapper.tsx @@ -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 {alt +} + +export function EmailIcon({ size = 32, className = '' }: { size?: number; className?: string }) { + return +} + +export function TerminalIcon({ size = 32, className = '' }: { size?: number; className?: string }) { + return +} + +export function FolderIcon({ size = 32, className = '' }: { size?: number; className?: string }) { + return +} + +export function DocumentIcon({ size = 32, className = '' }: { size?: number; className?: string }) { + return +} + +export function SettingsIcon({ size = 32, className = '' }: { size?: number; className?: string }) { + return +} + +export function NetworkIcon({ size = 32, className = '' }: { size?: number; className?: string }) { + return +} diff --git a/components/icons/index.tsx b/components/icons/index.tsx new file mode 100644 index 0000000..c022a7c --- /dev/null +++ b/components/icons/index.tsx @@ -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 ( + + + + ) +} + +export function SearchIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + + + ) +} + +export function TagIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + + + ) +} + +export function CalendarIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + + + ) +} + +export function ClockIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + + + ) +} diff --git a/content/blog/tech/articol-tehnic.md b/content/blog/tech/articol-tehnic.md index 87f4548..11a1acc 100644 --- a/content/blog/tech/articol-tehnic.md +++ b/content/blog/tech/articol-tehnic.md @@ -36,6 +36,10 @@ async function fetchUser(id: number): Promise { } ``` +### Use of coolers + +- ![Use of coolers](./cooler.jpg?w=400&h=300) + ## Concluzie Subdirectoarele funcționează perfect pentru organizarea conținutului! diff --git a/content/blog/tech/cooler.jpg b/content/blog/tech/cooler.jpg new file mode 100644 index 0000000..a30a210 Binary files /dev/null and b/content/blog/tech/cooler.jpg differ diff --git a/fix.js b/fix.js new file mode 100644 index 0000000..9bc554e --- /dev/null +++ b/fix.js @@ -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')) diff --git a/lib/image-utils.ts b/lib/image-utils.ts new file mode 100644 index 0000000..2c49d3c --- /dev/null +++ b/lib/image-utils.ts @@ -0,0 +1,85 @@ +import { promises as fs } from 'fs' +import path from 'path' + +export async function imageExists(imagePath: string): Promise { + 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, + } +} diff --git a/lib/markdown.ts b/lib/markdown.ts index 713a4d9..950423f 100644 --- a/lib/markdown.ts +++ b/lib/markdown.ts @@ -1,8 +1,11 @@ import fs from 'fs' import path from 'path' import matter from 'gray-matter' +import { remark } from 'remark' +import remarkGfm from 'remark-gfm' import { FrontMatter, Post } from './types/frontmatter' import { generateExcerpt } from './utils' +import { remarkCopyImages } from './remark-copy-images' 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 { const slugArray = Array.isArray(slug) ? slug : slug.split('/') const sanitized = slugArray.map(s => sanitizePath(s)) 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 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 { slug: sanitized.join('/'), frontmatter, - content, - readingTime: calculateReadingTime(content), - excerpt: generateExcerpt(content), + content: processedContent, + readingTime: calculateReadingTime(processedContent), + excerpt: generateExcerpt(processedContent), } } -export function getAllPosts(includeContent = false): Post[] { +export async function getAllPosts(includeContent = false): Promise { const posts: Post[] = [] - function walkDir(dir: string, prefix = ''): void { + async function walkDir(dir: string, prefix = ''): Promise { const files = fs.readdirSync(dir) for (const file of files) { @@ -85,11 +99,11 @@ export function getAllPosts(includeContent = false): Post[] { const stat = fs.statSync(filePath) if (stat.isDirectory()) { - walkDir(filePath, prefix ? `${prefix}/${file}` : file) + await walkDir(filePath, prefix ? `${prefix}/${file}` : file) } else if (file.endsWith('.md')) { const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, '') try { - const post = getPostBySlug(slug.split('/')) + const post = await getPostBySlug(slug.split('/')) if (post && !post.frontmatter.draft) { posts.push(includeContent ? post : { ...post, content: '' }) } @@ -101,7 +115,7 @@ export function getAllPosts(includeContent = false): Post[] { } if (fs.existsSync(POSTS_PATH)) { - walkDir(POSTS_PATH) + await walkDir(POSTS_PATH) } return posts.sort( @@ -110,10 +124,10 @@ export function getAllPosts(includeContent = false): Post[] { } export async function getRelatedPosts(currentSlug: string, limit = 3): Promise { - const currentPost = getPostBySlug(currentSlug) + const currentPost = await getPostBySlug(currentSlug) if (!currentPost) return [] - const allPosts = getAllPosts(false) + const allPosts = await getAllPosts(false) const { category, tags } = currentPost.frontmatter const scored = allPosts diff --git a/lib/remark-copy-images.ts b/lib/remark-copy-images.ts new file mode 100644 index 0000000..ac701c3 --- /dev/null +++ b/lib/remark-copy-images.ts @@ -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() + +async function copyAndRewritePath(node: ImageNode, options: Options): Promise { + 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[] = [] + + visit(tree, 'image', (node: Node) => { + const imageNode = node as ImageNode + if (isRelativePath(imageNode.url)) { + promises.push(copyAndRewritePath(imageNode, options)) + } + }) + + await Promise.all(promises) + } +} diff --git a/lib/tags.ts b/lib/tags.ts index f2b57da..8f9049f 100644 --- a/lib/tags.ts +++ b/lib/tags.ts @@ -1,15 +1,15 @@ -import { getAllPosts } from './markdown'; -import type { Post } from './types/frontmatter'; +import { getAllPosts } from './markdown' +import type { Post } from './types/frontmatter' export interface TagInfo { - name: string; - slug: string; - count: number; + name: string + slug: string + count: number } export interface TagWithPosts { - tag: TagInfo; - posts: Post[]; + tag: TagInfo + posts: Post[] } export function slugifyTag(tag: string): string { @@ -22,110 +22,108 @@ export function slugifyTag(tag: string): string { .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '') .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); + .replace(/^-|-$/g, '') } export async function getAllTags(): Promise { - const posts = getAllPosts(); - const tagMap = new Map(); + const posts = await getAllPosts() + const tagMap = new Map() posts.forEach(post => { - const tags = post.frontmatter.tags?.filter(Boolean) || []; + const tags = post.frontmatter.tags?.filter(Boolean) || [] tags.forEach(tag => { - const count = tagMap.get(tag) || 0; - tagMap.set(tag, count + 1); - }); - }); + const count = tagMap.get(tag) || 0 + tagMap.set(tag, count + 1) + }) + }) return Array.from(tagMap.entries()) .map(([name, count]) => ({ 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 { - const posts = getAllPosts(); + const posts = await getAllPosts() return posts.filter(post => { - const tags = post.frontmatter.tags?.filter(Boolean) || []; - return tags.some(tag => slugifyTag(tag) === tagSlug); - }); + const tags = post.frontmatter.tags?.filter(Boolean) || [] + return tags.some(tag => slugifyTag(tag) === tagSlug) + }) } export async function getTagInfo(tagSlug: string): Promise { - const allTags = await getAllTags(); - return allTags.find(tag => tag.slug === tagSlug) || null; + const allTags = await getAllTags() + return allTags.find(tag => tag.slug === tagSlug) || null } export async function getPopularTags(limit = 10): Promise { - const allTags = await getAllTags(); - return allTags.slice(0, limit); + const allTags = await getAllTags() + return allTags.slice(0, limit) } export async function getRelatedTags(tagSlug: string, limit = 5): Promise { - const posts = await getPostsByTag(tagSlug); - const relatedTagMap = new Map(); + const posts = await getPostsByTag(tagSlug) + const relatedTagMap = new Map() posts.forEach(post => { - const tags = post.frontmatter.tags?.filter(Boolean) || []; + const tags = post.frontmatter.tags?.filter(Boolean) || [] tags.forEach(tag => { - const slug = slugifyTag(tag); + const slug = slugifyTag(tag) if (slug !== tagSlug) { - const count = relatedTagMap.get(tag) || 0; - relatedTagMap.set(tag, count + 1); + const count = relatedTagMap.get(tag) || 0 + relatedTagMap.set(tag, count + 1) } - }); - }); + }) + }) return Array.from(relatedTagMap.entries()) .map(([name, count]) => ({ name, slug: slugifyTag(name), - count + count, })) .sort((a, b) => b.count - a.count) - .slice(0, limit); + .slice(0, limit) } export function validateTags(tags: any): string[] { - if (!tags) return []; + if (!tags) return [] if (!Array.isArray(tags)) { - console.warn('Tags should be an array'); - return []; + console.warn('Tags should be an array') + return [] } - const validTags = tags - .filter(tag => tag && typeof tag === 'string') - .slice(0, 3); + const validTags = tags.filter(tag => tag && typeof tag === 'string').slice(0, 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> { - const tags = await getAllTags(); - if (tags.length === 0) return []; + const tags = await getAllTags() + if (tags.length === 0) return [] - const maxCount = Math.max(...tags.map(t => t.count)); - const minCount = Math.min(...tags.map(t => t.count)); - const range = maxCount - minCount || 1; + const maxCount = Math.max(...tags.map(t => t.count)) + const minCount = Math.min(...tags.map(t => t.count)) + const range = maxCount - minCount || 1 return tags.map(tag => { - const normalized = (tag.count - minCount) / range; - let size: 'sm' | 'md' | 'lg' | 'xl'; + const normalized = (tag.count - minCount) / range + let size: 'sm' | 'md' | 'lg' | 'xl' - if (normalized < 0.25) size = 'sm'; - else if (normalized < 0.5) size = 'md'; - else if (normalized < 0.75) size = 'lg'; - else size = 'xl'; + if (normalized < 0.25) size = 'sm' + else if (normalized < 0.5) size = 'md' + else if (normalized < 0.75) size = 'lg' + else size = 'xl' - return { ...tag, size }; - }); + return { ...tag, size } + }) } diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index afa69a3..27b76ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,8 @@ "remark": "^15.0.1", "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.17", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "unist-util-visit": "^5.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", diff --git a/package.json b/package.json index 7635cb5..38b53f3 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "remark": "^15.0.1", "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.17", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "unist-util-visit": "^5.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1",