diff --git a/app/@breadcrumbs/about/page.tsx b/app/[locale]/@breadcrumbs/about/page.tsx similarity index 100% rename from app/@breadcrumbs/about/page.tsx rename to app/[locale]/@breadcrumbs/about/page.tsx diff --git a/app/@breadcrumbs/blog/[...slug]/page.tsx b/app/[locale]/@breadcrumbs/blog/[...slug]/page.tsx similarity index 100% rename from app/@breadcrumbs/blog/[...slug]/page.tsx rename to app/[locale]/@breadcrumbs/blog/[...slug]/page.tsx diff --git a/app/@breadcrumbs/blog/page.tsx b/app/[locale]/@breadcrumbs/blog/page.tsx similarity index 100% rename from app/@breadcrumbs/blog/page.tsx rename to app/[locale]/@breadcrumbs/blog/page.tsx diff --git a/app/@breadcrumbs/default.tsx b/app/[locale]/@breadcrumbs/default.tsx similarity index 100% rename from app/@breadcrumbs/default.tsx rename to app/[locale]/@breadcrumbs/default.tsx diff --git a/app/@breadcrumbs/tags/[tag]/page.tsx b/app/[locale]/@breadcrumbs/tags/[tag]/page.tsx similarity index 100% rename from app/@breadcrumbs/tags/[tag]/page.tsx rename to app/[locale]/@breadcrumbs/tags/[tag]/page.tsx diff --git a/app/@breadcrumbs/tags/page.tsx b/app/[locale]/@breadcrumbs/tags/page.tsx similarity index 100% rename from app/@breadcrumbs/tags/page.tsx rename to app/[locale]/@breadcrumbs/tags/page.tsx diff --git a/app/about/page.tsx b/app/[locale]/about/page.tsx similarity index 98% rename from app/about/page.tsx rename to app/[locale]/about/page.tsx index 0fc8e30..7b0e750 100644 --- a/app/about/page.tsx +++ b/app/[locale]/about/page.tsx @@ -1,12 +1,19 @@ import { Metadata } from 'next' import { Navbar } from '@/components/blog/navbar' +import {setRequestLocale} from 'next-intl/server' export const metadata: Metadata = { title: 'About', description: 'Learn more about me and this blog', } -export default function AboutPage() { +type Props = { + params: Promise<{locale: string}> +} + +export default async function AboutPage({params}: Props) { + const {locale} = await params + setRequestLocale(locale) return ( <> diff --git a/app/blog/[...slug]/not-found.tsx b/app/[locale]/blog/[...slug]/not-found.tsx similarity index 74% rename from app/blog/[...slug]/not-found.tsx rename to app/[locale]/blog/[...slug]/not-found.tsx index 7aa1f31..b0e9602 100644 --- a/app/blog/[...slug]/not-found.tsx +++ b/app/[locale]/blog/[...slug]/not-found.tsx @@ -1,26 +1,29 @@ -import Link from 'next/link' +import { useTranslations } from 'next-intl' +import { Link } from '@/i18n/navigation' export default function NotFound() { + const t = useTranslations('NotFound') + return (

404

-

Articolul nu a fost găsit

+

{t('title')}

- Ne pare rău, dar articolul pe care îl cauți nu există sau a fost mutat. + {t('description')}

- Vezi toate articolele + {t('goHome')} - Pagina principală + {t('goHome')}
diff --git a/app/blog/[...slug]/page.tsx b/app/[locale]/blog/[...slug]/page.tsx similarity index 91% rename from app/blog/[...slug]/page.tsx rename to app/[locale]/blog/[...slug]/page.tsx index 7373e0f..4cb18c0 100644 --- a/app/blog/[...slug]/page.tsx +++ b/app/[locale]/blog/[...slug]/page.tsx @@ -1,26 +1,36 @@ import { Metadata } from 'next' import { notFound } from 'next/navigation' -import Link from 'next/link' +import { Link } from '@/src/i18n/navigation' import { getAllPosts, getPostBySlug, getRelatedPosts } from '@/lib/markdown' import { formatDate, formatRelativeDate } from '@/lib/utils' import { TableOfContents } from '@/components/blog/table-of-contents' import { ReadingProgress } from '@/components/blog/reading-progress' import { StickyFooter } from '@/components/blog/sticky-footer' import MarkdownRenderer from '@/components/blog/markdown-renderer' +import { setRequestLocale } from 'next-intl/server' +import { routing } from '@/src/i18n/routing' export async function generateStaticParams() { - const posts = await getAllPosts() - return posts.map(post => ({ slug: post.slug.split('/') })) + const locales = ['en', 'ro'] + const allParams: Array<{ locale: string; slug: string[] }> = [] + + for (const locale of locales) { + const posts = await getAllPosts(locale) + posts.forEach(post => { + allParams.push({ locale, slug: post.slug.split('/') }) + }) + } + return allParams } export async function generateMetadata({ params, }: { - params: Promise<{ slug: string[] }> + params: Promise<{ locale: string; slug: string[] }> }): Promise { - const { slug } = await params + const { slug, locale } = await params const slugPath = slug.join('/') - const post = await getPostBySlug(slugPath) + const post = await getPostBySlug(slugPath, locale) if (!post) { return { title: 'Articol negăsit' } @@ -65,10 +75,14 @@ function extractHeadings(content: string) { return headings } -export default async function BlogPostPage({ params }: { params: Promise<{ slug: string[] }> }) { - const { slug } = await params +export default async function BlogPostPage({ + params, +}: { + params: Promise<{ locale: string; slug: string[] }> +}) { + const { slug, locale } = await params const slugPath = slug.join('/') - const post = await getPostBySlug(slugPath) + const post = await getPostBySlug(slugPath, locale) if (!post) { notFound() diff --git a/app/blog/blog-client.tsx b/app/[locale]/blog/blog-client.tsx similarity index 96% rename from app/blog/blog-client.tsx rename to app/[locale]/blog/blog-client.tsx index 6af0ce7..43ad88d 100644 --- a/app/blog/blog-client.tsx +++ b/app/[locale]/blog/blog-client.tsx @@ -1,6 +1,7 @@ 'use client' import { useMemo, useState } from 'react' +import { useTranslations } from 'next-intl' import { Post } from '@/lib/types/frontmatter' import { BlogCard } from '@/components/blog/blog-card' import { SearchBar } from '@/components/blog/search-bar' @@ -16,6 +17,7 @@ interface BlogPageClientProps { type SortOption = 'newest' | 'oldest' | 'title' export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) { + const t = useTranslations('BlogListing') const [searchQuery, setSearchQuery] = useState('') const [selectedTags, setSelectedTags] = useState([]) const [sortBy, setSortBy] = useState('newest') @@ -67,10 +69,10 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) {/* Header */}

- DATABASE QUERY // SEARCH RESULTS + {t("subtitle")}

- > BLOG ARCHIVE_ + > {t("title")}_

@@ -102,8 +104,8 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) {/* Results Count */}

- FOUND {filteredAndSortedPosts.length}{' '} - {filteredAndSortedPosts.length === 1 ? 'POST' : 'POSTS'} + {t("foundPosts", {count: filteredAndSortedPosts.length})}{' '} +

@@ -126,7 +128,7 @@ export default function BlogPageClient({ posts, allTags }: BlogPageClientProps) ) : (

- NO POSTS FOUND // TRY DIFFERENT SEARCH TERMS + {t("noPosts")}

)} diff --git a/app/blog/layout.tsx b/app/[locale]/blog/layout.tsx similarity index 100% rename from app/blog/layout.tsx rename to app/[locale]/blog/layout.tsx diff --git a/app/blog/page.tsx b/app/[locale]/blog/page.tsx similarity index 86% rename from app/blog/page.tsx rename to app/[locale]/blog/page.tsx index 4d2b83e..485362e 100644 --- a/app/blog/page.tsx +++ b/app/[locale]/blog/page.tsx @@ -1,5 +1,6 @@ import { getAllPosts } from '@/lib/markdown' import BlogPageClient from './blog-client' +import {setRequestLocale} from 'next-intl/server' export default async function BlogPage() { const posts = await getAllPosts() diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..009cb7c --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,35 @@ +import {notFound} from 'next/navigation' +import {setRequestLocale} from 'next-intl/server' +import {routing} from '@/src/i18n/routing' +import {ReactNode} from 'react' + +type Props = { + children: ReactNode + breadcrumbs: ReactNode + params: Promise<{locale: string}> +} + +export function generateStaticParams() { + return routing.locales.map((locale) => ({locale})) +} + +export default async function LocaleLayout({ + children, + breadcrumbs, + params +}: Props) { + const {locale} = await params + + if (!routing.locales.includes(locale as any)) { + notFound() + } + + setRequestLocale(locale) + + return ( + <> + {breadcrumbs} +
{children}
+ + ) +} diff --git a/app/page.tsx b/app/[locale]/page.tsx similarity index 99% rename from app/page.tsx rename to app/[locale]/page.tsx index 4bb3d7a..58ab27e 100644 --- a/app/page.tsx +++ b/app/[locale]/page.tsx @@ -1,8 +1,9 @@ -import Link from 'next/link' +import {Link} from '@/src/i18n/navigation' import Image from 'next/image' import { getAllPosts } from '@/lib/markdown' import { formatDate } from '@/lib/utils' import { ThemeToggle } from '@/components/theme-toggle' +import {setRequestLocale} from 'next-intl/server' export default async function HomePage() { const allPosts = await getAllPosts() diff --git a/app/tags/[tag]/not-found.tsx b/app/[locale]/tags/[tag]/not-found.tsx similarity index 100% rename from app/tags/[tag]/not-found.tsx rename to app/[locale]/tags/[tag]/not-found.tsx diff --git a/app/tags/[tag]/page.tsx b/app/[locale]/tags/[tag]/page.tsx similarity index 97% rename from app/tags/[tag]/page.tsx rename to app/[locale]/tags/[tag]/page.tsx index 23fcbd8..eb1c06d 100644 --- a/app/tags/[tag]/page.tsx +++ b/app/[locale]/tags/[tag]/page.tsx @@ -1,9 +1,11 @@ import { Metadata } from 'next' import { notFound } from 'next/navigation' -import Link from 'next/link' +import {Link} from '@/src/i18n/navigation' import { getAllTags, getPostsByTag, getTagInfo, getRelatedTags } from '@/lib/tags' import { TagList } from '@/components/blog/tag-list' import { formatDate } from '@/lib/utils' +import {setRequestLocale} from 'next-intl/server' +import {routing} from '@/src/i18n/routing' export async function generateStaticParams() { const tags = await getAllTags() @@ -13,7 +15,7 @@ export async function generateStaticParams() { export async function generateMetadata({ params, }: { - params: Promise<{ tag: string }> + params: Promise<{ locale: string; tag: string }> }): Promise { const { tag } = await params const tagInfo = await getTagInfo(tag) diff --git a/app/tags/layout.tsx b/app/[locale]/tags/layout.tsx similarity index 100% rename from app/tags/layout.tsx rename to app/[locale]/tags/layout.tsx diff --git a/app/tags/page.tsx b/app/[locale]/tags/page.tsx similarity index 95% rename from app/tags/page.tsx rename to app/[locale]/tags/page.tsx index 3b8e76b..156dd73 100644 --- a/app/tags/page.tsx +++ b/app/[locale]/tags/page.tsx @@ -1,15 +1,22 @@ import { Metadata } from 'next' -import Link from 'next/link' +import {Link} from '@/src/i18n/navigation' import { getAllTags, getTagCloud } from '@/lib/tags' import { TagCloud } from '@/components/blog/tag-cloud' import { TagBadge } from '@/components/blog/tag-badge' +import {setRequestLocale} from 'next-intl/server' export const metadata: Metadata = { title: 'Tag-uri', description: 'Explorează articolele după tag-uri', } -export default async function TagsPage() { +type Props = { + params: Promise<{locale: string}> +} + +export default async function TagsPage({params}: Props) { + const {locale} = await params + setRequestLocale(locale) const allTags = await getAllTags() const tagCloud = await getTagCloud() diff --git a/app/feed.xml/route.ts b/app/feed.xml/route.ts index 82ec6a7..e1a42c1 100644 --- a/app/feed.xml/route.ts +++ b/app/feed.xml/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' export async function GET() { const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3030' - const posts = await getAllPosts(false) + const posts = await getAllPosts("en", false) const rss = ` diff --git a/app/globals.css b/app/globals.css index 8e92fc5..7d82f03 100644 --- a/app/globals.css +++ b/app/globals.css @@ -17,6 +17,7 @@ --text-muted: 113 113 122; --border-primary: 212 212 216; --border-subtle: 228 228 231; + --text-color: #1f1f1f; /* Desaturated cyberpunk for light mode - darker for readability */ --neon-pink: #7a3d52; @@ -35,6 +36,7 @@ --text-muted: 100 116 139; --border-primary: 71 85 105; --border-subtle: 30 41 59; + --text-color: #d4d4d8; /* Desaturated cyberpunk for dark mode */ --neon-pink: #8a5568; @@ -313,7 +315,7 @@ /* Cyberpunk Prose Styling */ .cyberpunk-prose { - color: rgb(212 212 216); + color: var(--text-color); } .cyberpunk-prose h1, @@ -347,7 +349,6 @@ } .cyberpunk-prose p { - color: rgb(212 212 216); line-height: 1.625; margin-bottom: 1.5rem; font-size: 1.125rem; @@ -366,7 +367,6 @@ .cyberpunk-prose ul, .cyberpunk-prose ol { - color: rgb(212 212 216); padding-left: 1.5rem; margin-bottom: 1.5rem; } diff --git a/app/layout.tsx b/app/layout.tsx index 1712019..9de5d47 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,9 @@ import type { Metadata } from 'next' import { JetBrains_Mono } from 'next/font/google' import './globals.css' import { ThemeProvider } from '@/providers/providers' -import '@/lib/env-validation' // Validate environment variables +import '@/lib/env-validation' +import {NextIntlClientProvider} from 'next-intl' +import {getMessages} from 'next-intl/server' const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' }) @@ -17,7 +19,6 @@ export const metadata: Metadata = { keywords: ['blog', 'dezvoltare web', 'nextjs', 'react', 'typescript', 'terminal'], openGraph: { type: 'website', - locale: 'ro_RO', siteName: 'Terminal Blog', }, robots: { @@ -29,9 +30,15 @@ export const metadata: Metadata = { }, } -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ + children +}: { + children: React.ReactNode +}) { + const messages = await getMessages() + return ( - + -
-
{children}
+ +
+
{children}
- {/* Footer - from worktree-agent-1 */} -
-
-
-

- © 2025 // BLOG &{' '} - RANDOM THOUGHTS{' '} - // ALL RIGHTS RESERVED -

+
+
+
+

+ © 2025 // BLOG &{' '} + RANDOM THOUGHTS{' '} + // ALL RIGHTS RESERVED +

+
-
-
-
+ +
+
diff --git a/app/sitemap.ts b/app/sitemap.ts index d7dcb11..9325927 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -5,7 +5,7 @@ export default async function sitemap(): Promise { const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3030' // Get all blog posts - const posts = await getAllPosts(false) + const posts = await getAllPosts("en", false) // Generate sitemap entries for blog posts const blogPosts: MetadataRoute.Sitemap = posts.map(post => ({ diff --git a/components/blog/blog-card.tsx b/components/blog/blog-card.tsx index aebba54..aaaa1b5 100644 --- a/components/blog/blog-card.tsx +++ b/components/blog/blog-card.tsx @@ -1,4 +1,5 @@ -import Link from 'next/link' +import { useTranslations } from 'next-intl' +import { Link } from '@/i18n/navigation' import Image from 'next/image' import { Post } from '@/lib/types/frontmatter' import { formatDate } from '@/lib/utils' @@ -9,6 +10,7 @@ interface BlogCardProps { } export function BlogCard({ post, variant }: BlogCardProps) { + const t = useTranslations('BlogPost') const hasImage = !!post.frontmatter.image if (!hasImage || variant === 'text-only') { @@ -38,7 +40,7 @@ export function BlogCard({ post, variant }: BlogCardProps) { ))}
- > READ [{post.readingTime}MIN] + > {t('readingTime', {minutes: post.readingTime})} @@ -82,7 +84,7 @@ export function BlogCard({ post, variant }: BlogCardProps) { ))} - > READ [{post.readingTime}MIN] + > {t('readingTime', {minutes: post.readingTime})} @@ -127,7 +129,7 @@ export function BlogCard({ post, variant }: BlogCardProps) { ))} - > READ [{post.readingTime}MIN] + > {t('readingTime', {minutes: post.readingTime})} diff --git a/components/blog/markdown-renderer.tsx b/components/blog/markdown-renderer.tsx index 9254205..6162475 100644 --- a/components/blog/markdown-renderer.tsx +++ b/components/blog/markdown-renderer.tsx @@ -6,7 +6,8 @@ import rehypeSanitize from 'rehype-sanitize' import rehypeRaw from 'rehype-raw' import { OptimizedImage } from './OptimizedImage' import { CodeBlock } from './code-block' -import Link from 'next/link' +import { useLocale } from 'next-intl' +import { Link } from '@/i18n/navigation' interface MarkdownRendererProps { content: string @@ -14,6 +15,7 @@ interface MarkdownRendererProps { } export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) { + const locale = useLocale() return (
- < HOME + < {t('home')} - // BLOG ARCHIVE + // {t('blog')} ARCHIVE
@@ -50,15 +53,16 @@ export function Navbar() { href="/about" className="font-mono text-sm text-zinc-400 dark:text-zinc-500 uppercase tracking-wider hover:text-cyan-400 dark:hover:text-cyan-300 transition-colors cursor-pointer" > - [ABOUT] + [{t('about')}] - [BLOG] + [{t('blog')}] - + +
diff --git a/components/blog/popular-tags.tsx b/components/blog/popular-tags.tsx index 711ae29..8ea1217 100644 --- a/components/blog/popular-tags.tsx +++ b/components/blog/popular-tags.tsx @@ -3,7 +3,7 @@ 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("en", limit) if (tags.length === 0) return null diff --git a/components/blog/reading-progress.tsx b/components/blog/reading-progress.tsx index aa8d116..19c4ff8 100644 --- a/components/blog/reading-progress.tsx +++ b/components/blog/reading-progress.tsx @@ -30,9 +30,9 @@ export function ReadingProgress() { /> -
- [{Math.round(progress)}%] -
+ {/*
+ [{Math.round(progress)}%] +
*/} ) } diff --git a/components/blog/sticky-footer.tsx b/components/blog/sticky-footer.tsx index 2323426..13dff9b 100644 --- a/components/blog/sticky-footer.tsx +++ b/components/blog/sticky-footer.tsx @@ -43,17 +43,12 @@ export function StickyFooter({ url, title }: StickyFooterProps) { className={` fixed bottom-0 left-0 right-0 z-40 bg-black/98 backdrop-blur-sm - border-t-4 border-[var(--neon-magenta)] + border-t-1 border-[var(--neon-magenta)] transition-transform duration-200 ease-in-out ${isVisible ? 'translate-y-0' : 'translate-y-full'} `} - style={{ - boxShadow: isVisible - ? '0 -8px 30px rgba(155,90,142,0.5), inset 0 4px 20px rgba(155,90,142,0.1)' - : 'none', - }} > -
+
diff --git a/components/blog/tag-cloud.tsx b/components/blog/tag-cloud.tsx index 1224d1c..26d258f 100644 --- a/components/blog/tag-cloud.tsx +++ b/components/blog/tag-cloud.tsx @@ -1,4 +1,5 @@ -import Link from 'next/link' +import { useTranslations } from 'next-intl' +import { Link } from '@/i18n/navigation' import { TagInfo } from '@/lib/tags' interface TagCloudProps { @@ -6,6 +7,7 @@ interface TagCloudProps { } export function TagCloud({ tags }: TagCloudProps) { + const t = useTranslations('Tags') const sizeClasses = { sm: 'text-xs opacity-70', md: 'text-sm', @@ -26,7 +28,7 @@ export function TagCloud({ tags }: TagCloudProps) { hover:text-cyan-400 transition-colors `} - title={`${tag.count} ${tag.count === 1 ? 'articol' : 'articole'}`} + title={t('postsWithTag', {count: tag.count, tag: tag.name})} > #{tag.name} diff --git a/components/layout/Breadcrumbs.tsx b/components/layout/Breadcrumbs.tsx index cebe86c..57f9fa5 100644 --- a/components/layout/Breadcrumbs.tsx +++ b/components/layout/Breadcrumbs.tsx @@ -1,7 +1,8 @@ 'use client' -import Link from 'next/link' +import {Link} from '@/i18n/navigation' import { usePathname } from 'next/navigation' +import { useLocale, useTranslations } from 'next-intl' import { Fragment } from 'react' import { BreadcrumbsSchema } from './breadcrumbs-schema' @@ -38,25 +39,33 @@ function ChevronIcon({ className }: { className?: string }) { ) } -function formatSegmentLabel(segment: string): string { - const specialCases: { [key: string]: string } = { - blog: 'Blog', - tags: 'Tag-uri', - about: 'Despre', - } - - if (specialCases[segment]) { - return specialCases[segment] - } - - return segment - .split('-') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') -} - export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) { const pathname = usePathname() + const locale = useLocale() + const t = useTranslations('Breadcrumbs') + + // Hide breadcrumbs on main page + const isMainPage = pathname === `/${locale}` || pathname === '/' + if (isMainPage) { + return null + } + + const formatSegmentLabel = (segment: string): string => { + const specialCases: { [key: string]: string } = { + blog: t('blog'), + tags: t('tags'), + about: t('about'), + } + + if (specialCases[segment]) { + return specialCases[segment] + } + + return segment + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + } let breadcrumbs: BreadcrumbItem[] = items || [] @@ -71,12 +80,8 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) { }) } - if (pathname === '/') { - return null - } - const schemaItems = [ - { position: 1, name: 'Acasă', item: '/' }, + { position: 1, name: t('home'), item: '/' }, ...breadcrumbs.map((item, index) => ({ position: index + 2, name: item.label, @@ -96,7 +101,7 @@ export function Breadcrumbs({ items }: { items?: BreadcrumbItem[] }) { diff --git a/components/layout/LanguageSwitcher.tsx b/components/layout/LanguageSwitcher.tsx new file mode 100644 index 0000000..078ea8f --- /dev/null +++ b/components/layout/LanguageSwitcher.tsx @@ -0,0 +1,59 @@ +'use client'; + +import {useLocale} from 'next-intl'; +import {useRouter, usePathname} from '@/i18n/navigation'; +import {routing} from '@/i18n/routing'; +import {useState} from 'react'; + +export default function LanguageSwitcher() { + const locale = useLocale(); + const router = useRouter(); + const pathname = usePathname(); + const [isOpen, setIsOpen] = useState(false); + + const handleLocaleChange = (newLocale: string) => { + router.replace(pathname, {locale: newLocale}); + router.refresh(); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( +
+ {routing.locales.map((loc: string) => ( + + ))} +
+ )} + + {isOpen && ( +
setIsOpen(false)} + /> + )} +
+ ); +} diff --git a/content/blog/why-this-page.md b/content/blog/en/why-this-page.md similarity index 100% rename from content/blog/why-this-page.md rename to content/blog/en/why-this-page.md diff --git a/content/blog/ro/why-this-page.md b/content/blog/ro/why-this-page.md new file mode 100644 index 0000000..085dbfd --- /dev/null +++ b/content/blog/ro/why-this-page.md @@ -0,0 +1,41 @@ +--- +title: 'Why I created this page' +description: 'First post' +date: '2025-12-02' +author: 'Rares' +category: 'Opinion' +tags: ['opinion'] +image: '' +draft: false +--- + +# De ce aceasta pagina? + +Daca te intrebi de ce aceata pagina? Pentru ca vreau sa jurnalizez lucrurile la care lucrez, sau gandurile pe care vreua sa le impartesesc. + +## Why a blog? + +You might be thinking, "Why create another tech blog? There are plenty out there." +Well, yes, there are. But I believe that sharing some of my opinions and experiences will eventually act out as a journal: + +1. **Personal touch**: Even though i've been working corporate all my career, this webpage won't contain that sugar coated language 😅. It's a place where you'll get to know me – my thoughts, my mistakes, and my victories. I believe that this personal touch makes the content more engaging and relatable. +2. **Beyond tech**: While I'll be writing about technology, I also want to explore other topics that interest me, such as mental health, productivity and so on.... I think a well-rounded approach can help create a more engaging and informative space. +3. **Self-hosting adventure**: As you might have guessed from the title, this blog is self-hosted. This was an exciting journey for me, and I'll be sharing my experiences, challenges, and learnings along the way. If you're interested in self-hosting or just want to understand what it's all about, you might find what worked for me or didn't. + +## Why self-host? + +![Selfhosting rig](./whythispage/selfhostedrig.gif?w=400 "My self-hosting setup | A look at the hardware running this blog") + +Now, let's talk about why I chose to self-host this blog. In a nutshell, self-hosting gave me: + +- **Full control**: By hosting my own website, I have complete control over my content and how it's displayed. No more compromises or limitations imposed by third-party platforms. +- **Owning my data**: It's just, that I can have control over my data without others snooping around. +- **It's fun**: Started looking into sysadmin/devops for a long time, after a burnout I stepped into selfhosting more convincingly. + +## What to expect + +As I mentioned earlier, this blog will be a mix of tech tutorials, personal thoughts, and everything in between. Here's what you can look forward to: + +- **Tech how-tos**: Step-by-step guides on various topics, from setting up your own development environment to configuring your server. +- **Self-hosting adventures**: My experiences, learnings, and tips on self-hosting, including challenges faced and solutions implemented. +- **Random musings**: Thoughts on productivity, mental health, and other interests of mine that might not be directly related to tech. diff --git a/lib/markdown.ts b/lib/markdown.ts index 8424557..fcbadee 100644 --- a/lib/markdown.ts +++ b/lib/markdown.ts @@ -33,7 +33,7 @@ export function calculateReadingTime(content: string): number { return Math.ceil(words / wordsPerMinute) } -export function validateFrontmatter(data: any): FrontMatter { +export function validateFrontmatter(data: any, locale?: string): FrontMatter { if (!data.title || typeof data.title !== 'string') { throw new Error('Invalid title') } @@ -60,15 +60,19 @@ export function validateFrontmatter(data: any): FrontMatter { author: data.author, category: data.category, tags: data.tags, + locale: data.locale || locale || 'en', image: data.image, draft: data.draft || false, } } -export async function getPostBySlug(slug: string | string[]): Promise { +export async function getPostBySlug( + slug: string | string[], + locale: string = 'en' +): Promise { const slugArray = Array.isArray(slug) ? slug : slug.split('/') const sanitized = slugArray.map(s => sanitizePath(s)) - const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md' + const fullPath = path.join(POSTS_PATH, locale, ...sanitized) + '.md' if (!fs.existsSync(fullPath)) { return null @@ -76,7 +80,7 @@ export async function getPostBySlug(slug: string | string[]): Promise { +export async function getAllPosts(locale: string = 'en', includeContent = false): Promise { const posts: Post[] = [] + const localeDir = path.join(POSTS_PATH, locale) + + if (!fs.existsSync(localeDir)) { + console.warn(`Locale directory not found: ${localeDir}`) + return [] + } async function walkDir(dir: string, prefix = ''): Promise { const files = fs.readdirSync(dir) @@ -114,7 +125,7 @@ export async function getAllPosts(includeContent = false): Promise { } else if (file.endsWith('.md')) { const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, '') try { - const post = await getPostBySlug(slug.split('/')) + const post = await getPostBySlug(slug.split('/'), locale) if (post && !post.frontmatter.draft) { posts.push(includeContent ? post : { ...post, content: '' }) } @@ -125,20 +136,22 @@ export async function getAllPosts(includeContent = false): Promise { } } - if (fs.existsSync(POSTS_PATH)) { - await walkDir(POSTS_PATH) - } + await walkDir(localeDir) return posts.sort( (a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime() ) } -export async function getRelatedPosts(currentSlug: string, limit = 3): Promise { - const currentPost = await getPostBySlug(currentSlug) +export async function getRelatedPosts( + currentSlug: string, + locale: string = 'en', + limit = 3 +): Promise { + const currentPost = await getPostBySlug(currentSlug, locale) if (!currentPost) return [] - const allPosts = await getAllPosts(false) + const allPosts = await getAllPosts(locale, false) const { category, tags } = currentPost.frontmatter const scored = allPosts @@ -155,8 +168,13 @@ export async function getRelatedPosts(currentSlug: string, limit = 3): Promise

post) } -export function getAllPostSlugs(): string[][] { +export function getAllPostSlugs(locale: string = 'en'): string[][] { const slugs: string[][] = [] + const localeDir = path.join(POSTS_PATH, locale) + + if (!fs.existsSync(localeDir)) { + return [] + } function walkDir(dir: string, prefix: string[] = []): void { const files = fs.readdirSync(dir) @@ -173,9 +191,26 @@ export function getAllPostSlugs(): string[][] { } } - if (fs.existsSync(POSTS_PATH)) { - walkDir(POSTS_PATH) - } + walkDir(localeDir) return slugs } + +export async function getAvailableLocales(slug: string): Promise { + const locales = ['en', 'ro'] + const available: string[] = [] + + for (const locale of locales) { + const post = await getPostBySlug(slug, locale) + if (post) { + available.push(locale) + } + } + + return available +} + +export async function getPostCount(locale: string): Promise { + const posts = await getAllPosts(locale, false) + return posts.length +} diff --git a/lib/remark-internal-links.ts b/lib/remark-internal-links.ts index 34bf291..07d3f2e 100644 --- a/lib/remark-internal-links.ts +++ b/lib/remark-internal-links.ts @@ -7,6 +7,10 @@ interface LinkNode extends Node { children: Node[] } +interface Options { + locale?: string +} + /** * Detects internal blog post links: * - Relative paths (no http/https) @@ -24,11 +28,11 @@ function isInternalBlogLink(url: string): boolean { /** * Transforms internal .md links to blog routes: - * - tech/article.md → /blog/tech/article - * - article.md#section → /blog/article#section - * - nested/path/post.md?ref=foo → /blog/nested/path/post?ref=foo + * - tech/article.md → /[locale]/blog/tech/article + * - article.md#section → /[locale]/blog/article#section + * - nested/path/post.md?ref=foo → /[locale]/blog/nested/path/post?ref=foo */ -function transformToBlogPath(url: string): string { +function transformToBlogPath(url: string, locale: string = 'en'): string { // Split into path, hash, and query const hashIndex = url.indexOf('#') const queryIndex = url.indexOf('?') @@ -50,17 +54,19 @@ function transformToBlogPath(url: string): string { // Remove .md extension const cleanPath = path.replace(/\.md$/, '') - // Build final URL - return `/blog/${cleanPath}${query}${hash}` + // Build final URL with locale prefix + return `/${locale}/blog/${cleanPath}${query}${hash}` } -export function remarkInternalLinks() { +export function remarkInternalLinks(options: Options = {}) { + const locale = options.locale || 'en' + return (tree: Node) => { visit(tree, 'link', (node: Node) => { const linkNode = node as LinkNode if (isInternalBlogLink(linkNode.url)) { - linkNode.url = transformToBlogPath(linkNode.url) + linkNode.url = transformToBlogPath(linkNode.url, locale) } }) } diff --git a/lib/tags.ts b/lib/tags.ts index 8f9049f..392929a 100644 --- a/lib/tags.ts +++ b/lib/tags.ts @@ -25,8 +25,8 @@ export function slugifyTag(tag: string): string { .replace(/^-|-$/g, '') } -export async function getAllTags(): Promise { - const posts = await getAllPosts() +export async function getAllTags(locale: string = 'en'): Promise { + const posts = await getAllPosts(locale) const tagMap = new Map() posts.forEach(post => { @@ -46,8 +46,8 @@ export async function getAllTags(): Promise { .sort((a, b) => b.count - a.count) } -export async function getPostsByTag(tagSlug: string): Promise { - const posts = await getAllPosts() +export async function getPostsByTag(tagSlug: string, locale: string = 'en'): Promise { + const posts = await getAllPosts(locale) return posts.filter(post => { const tags = post.frontmatter.tags?.filter(Boolean) || [] @@ -55,18 +55,18 @@ export async function getPostsByTag(tagSlug: string): Promise { }) } -export async function getTagInfo(tagSlug: string): Promise { - const allTags = await getAllTags() +export async function getTagInfo(tagSlug: string, locale: string = 'en'): Promise { + const allTags = await getAllTags(locale) return allTags.find(tag => tag.slug === tagSlug) || null } -export async function getPopularTags(limit = 10): Promise { - const allTags = await getAllTags() +export async function getPopularTags(locale: string = 'en', limit = 10): Promise { + const allTags = await getAllTags(locale) return allTags.slice(0, limit) } -export async function getRelatedTags(tagSlug: string, limit = 5): Promise { - const posts = await getPostsByTag(tagSlug) +export async function getRelatedTags(tagSlug: string, locale: string = 'en', limit = 5): Promise { + const posts = await getPostsByTag(tagSlug, locale) const relatedTagMap = new Map() posts.forEach(post => { @@ -107,8 +107,8 @@ export function validateTags(tags: any): string[] { return validTags } -export async function getTagCloud(): Promise> { - const tags = await getAllTags() +export async function getTagCloud(locale: string = 'en'): Promise> { + const tags = await getAllTags(locale) if (tags.length === 0) return [] const maxCount = Math.max(...tags.map(t => t.count)) diff --git a/lib/types/frontmatter.ts b/lib/types/frontmatter.ts index 9acb99c..d098b7c 100644 --- a/lib/types/frontmatter.ts +++ b/lib/types/frontmatter.ts @@ -5,12 +5,14 @@ export interface FrontMatter { author: string category: string tags: string[] + locale: string image?: string draft?: boolean } export interface Post { slug: string + locale: string frontmatter: FrontMatter content: string readingTime: number diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..af3d382 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,69 @@ +{ + "Metadata": { + "siteTitle": "Personal Blog", + "siteDescription": "Thoughts on technology and development" + }, + + "Navigation": { + "home": "Home", + "blog": "Blog", + "tags": "Tags", + "about": "About" + }, + + "Breadcrumbs": { + "home": "Home", + "blog": "Blog", + "tags": "Tags", + "about": "About" + }, + + "BlogListing": { + "title": "Blog", + "subtitle": "Latest articles and thoughts", + "searchPlaceholder": "Search articles...", + "sortBy": "Sort by", + "sortNewest": "Newest", + "sortOldest": "Oldest", + "sortTitle": "Title", + "filterByTag": "Filter by tag", + "clearFilters": "Clear filters", + "foundPosts": "Found {count} posts", + "noPosts": "No posts found" + }, + + "BlogPost": { + "readMore": "Read more", + "readingTime": "{minutes} min read", + "publishedOn": "Published on {date}", + "author": "By {author}", + "tags": "Tags", + "relatedPosts": "Related Posts", + "sharePost": "Share this post" + }, + + "Tags": { + "title": "Tags", + "subtitle": "Browse by topic", + "allTags": "All Tags", + "postsWithTag": "{count} posts tagged with {tag}", + "relatedTags": "Related tags", + "quickNav": "Quick navigation" + }, + + "About": { + "title": "About", + "subtitle": "Learn more about me" + }, + + "NotFound": { + "title": "Page Not Found", + "description": "The page you're looking for doesn't exist", + "goHome": "Go to homepage" + }, + + "LanguageSwitcher": { + "switchLanguage": "Switch language", + "currentLanguage": "Current language" + } +} diff --git a/messages/ro.json b/messages/ro.json new file mode 100644 index 0000000..d0077f2 --- /dev/null +++ b/messages/ro.json @@ -0,0 +1,69 @@ +{ + "Metadata": { + "siteTitle": "Blog Personal", + "siteDescription": "Gânduri despre tehnologie și dezvoltare" + }, + + "Navigation": { + "home": "Acasă", + "blog": "Blog", + "tags": "Etichete", + "about": "Despre" + }, + + "Breadcrumbs": { + "home": "Acasă", + "blog": "Blog", + "tags": "Etichete", + "about": "Despre" + }, + + "BlogListing": { + "title": "Blog", + "subtitle": "Ultimele articole și gânduri", + "searchPlaceholder": "Caută articole...", + "sortBy": "Sortează după", + "sortNewest": "Cele mai noi", + "sortOldest": "Cele mai vechi", + "sortTitle": "Titlu", + "filterByTag": "Filtrează după etichetă", + "clearFilters": "Șterge filtrele", + "foundPosts": "{count} articole găsite", + "noPosts": "Niciun articol găsit" + }, + + "BlogPost": { + "readMore": "Citește mai mult", + "readingTime": "{minutes} min citire", + "publishedOn": "Publicat pe {date}", + "author": "De {author}", + "tags": "Etichete", + "relatedPosts": "Articole similare", + "sharePost": "Distribuie acest articol" + }, + + "Tags": { + "title": "Etichete", + "subtitle": "Navighează după subiect", + "allTags": "Toate etichetele", + "postsWithTag": "{count} articole cu eticheta {tag}", + "relatedTags": "Etichete similare", + "quickNav": "Navigare rapidă" + }, + + "About": { + "title": "Despre", + "subtitle": "Află mai multe despre mine" + }, + + "NotFound": { + "title": "Pagina nu a fost găsită", + "description": "Pagina pe care o cauți nu există", + "goHome": "Mergi la pagina principală" + }, + + "LanguageSwitcher": { + "switchLanguage": "Schimbă limba", + "currentLanguage": "Limba curentă" + } +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..5d0d195 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,20 @@ +import createMiddleware from 'next-intl/middleware'; +import {routing} from './src/i18n/routing'; + +export default createMiddleware({ + ...routing, + localeDetection: true, + localeCookie: { + name: 'NEXT_LOCALE', + maxAge: 60 * 60 * 24 * 365, + sameSite: 'lax' + } +}); + +export const config = { + matcher: [ + '/', + '/(en|ro)/:path*', + '/((?!api|_next|_vercel|.*\\..*).*)' + ] +}; diff --git a/next.config.js b/next.config.js index 220d49d..492484c 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,5 @@ +const withNextIntl = require('next-intl/plugin')(); + /** @type {import('next').NextConfig} */ // ============================================ // Next.js 16 Configuration @@ -245,4 +247,4 @@ const nextConfig = { // }, } -module.exports = nextConfig +module.exports = withNextIntl(nextConfig) diff --git a/package-lock.json b/package-lock.json index 8404ee6..06ae107 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "autoprefixer": "^10.4.21", "gray-matter": "^4.0.3", "next": "^16.0.1", + "next-intl": "^4.5.7", "next-themes": "^0.4.6", "postcss": "^8.5.6", "react": "^19.2.0", @@ -566,6 +567,66 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.2", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", + "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", + "license": "MIT", + "dependencies": { + "tslib": "2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1399,6 +1460,178 @@ "dev": true, "license": "MIT" }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.3.tgz", + "integrity": "sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.3.tgz", + "integrity": "sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.3.tgz", + "integrity": "sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.3.tgz", + "integrity": "sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.3.tgz", + "integrity": "sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.3.tgz", + "integrity": "sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.3.tgz", + "integrity": "sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.3.tgz", + "integrity": "sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.3.tgz", + "integrity": "sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.3.tgz", + "integrity": "sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1408,6 +1641,15 @@ "tslib": "^2.8.0" } }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", @@ -3027,6 +3269,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -4809,6 +5057,18 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.4", + "tslib": "^2.8.0" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -6737,6 +6997,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/next/-/next-16.0.1.tgz", @@ -6789,6 +7058,91 @@ } } }, + "node_modules/next-intl": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.5.7.tgz", + "integrity": "sha512-7iT9rBEFZvsJI5uLoOLgI1kAieg1k7zCwbuby6ylKRbpvt08I1vkZ5FJnIBey1M+r1jam/wANlnqRYeJagjL2Q==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", + "@swc/core": "^1.15.2", + "negotiator": "^1.0.0", + "next-intl-swc-plugin-extractor": "^4.5.7", + "po-parser": "^1.0.2", + "use-intl": "^4.5.7" + }, + "peerDependencies": { + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/next-intl-swc-plugin-extractor": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.5.7.tgz", + "integrity": "sha512-cSHtDpEoSHuEC4CzUDmAAfB0H3fqSephpJNd/GtS9LvUoZM78wJQwkEaqN9yTxXEvJ8uQG60nnOeSl2LQU9qdQ==", + "license": "MIT" + }, + "node_modules/next-intl/node_modules/@swc/core": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.3.tgz", + "integrity": "sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.3", + "@swc/core-darwin-x64": "1.15.3", + "@swc/core-linux-arm-gnueabihf": "1.15.3", + "@swc/core-linux-arm64-gnu": "1.15.3", + "@swc/core-linux-arm64-musl": "1.15.3", + "@swc/core-linux-x64-gnu": "1.15.3", + "@swc/core-linux-x64-musl": "1.15.3", + "@swc/core-win32-arm64-msvc": "1.15.3", + "@swc/core-win32-ia32-msvc": "1.15.3", + "@swc/core-win32-x64-msvc": "1.15.3" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -7139,6 +7493,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/po-parser": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-1.0.2.tgz", + "integrity": "sha512-yTIQL8PZy7V8c0psPoJUx7fayez+Mo/53MZgX9MPuPHx+Dt+sRPNuRbI+6Oqxnddhkd68x4Nlgon/zizL1Xg+w==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -8625,6 +8985,20 @@ "punycode": "^2.1.0" } }, + "node_modules/use-intl": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.5.7.tgz", + "integrity": "sha512-WBVD1fxV9td5osQFK0TRQhz217zHERhxBuA3EmZuH7wCINJPXbYPs+0FH2oMpy6p6BBwuHCJK2ER8hKwxf0LQA==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^2.2.0", + "@schummar/icu-type-parser": "1.21.5", + "intl-messageformat": "^10.5.14" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 15e497b..b09baf7 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "autoprefixer": "^10.4.21", "gray-matter": "^4.0.3", "next": "^16.0.1", + "next-intl": "^4.5.7", "next-themes": "^0.4.6", "postcss": "^8.5.6", "react": "^19.2.0", diff --git a/src/i18n/navigation.ts b/src/i18n/navigation.ts new file mode 100644 index 0000000..8f5a5e2 --- /dev/null +++ b/src/i18n/navigation.ts @@ -0,0 +1,5 @@ +import {createNavigation} from 'next-intl/navigation'; +import {routing} from './routing'; + +export const {Link, redirect, usePathname, useRouter} = + createNavigation(routing); diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 0000000..d00e5cb --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,15 @@ +import {getRequestConfig} from 'next-intl/server'; +import {routing} from './routing'; + +export default getRequestConfig(async ({requestLocale}) => { + let locale = await requestLocale; + + if (!locale || !routing.locales.includes(locale as any)) { + locale = routing.defaultLocale; + } + + return { + locale, + messages: (await import(`../../messages/${locale}.json`)).default + }; +}); diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts new file mode 100644 index 0000000..9d21ecb --- /dev/null +++ b/src/i18n/routing.ts @@ -0,0 +1,13 @@ +import {defineRouting} from 'next-intl/routing'; + +export const routing = defineRouting({ + locales: ['en', 'ro'], + defaultLocale: 'en', + localePrefix: 'always', + localeNames: { + en: 'English', + ro: 'Română' + } +} as any); + +export type Locale = (typeof routing.locales)[number]; diff --git a/tsconfig.json b/tsconfig.json index dcab4d1..3900e3a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2020", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,7 +23,12 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ], + "@/i18n/*": [ + "./src/i18n/*" + ] } }, "include": [ @@ -29,5 +38,7 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] -} + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/types/translations.d.ts b/types/translations.d.ts new file mode 100644 index 0000000..fc4e94f --- /dev/null +++ b/types/translations.d.ts @@ -0,0 +1,5 @@ +type Messages = typeof import('../messages/en.json'); + +declare global { + interface IntlMessages extends Messages {} +}