Merge pull request #1 from aliarghyani/publishBlog

This commit is contained in:
Ali Arghyani
2025-11-11 17:27:04 +03:30
committed by GitHub
69 changed files with 11396 additions and 1106 deletions
+1
View File
@@ -3,6 +3,7 @@
.nuxt .nuxt
.nitro .nitro
.cache .cache
.data
dist dist
# Node dependencies # Node dependencies
+394
View File
@@ -0,0 +1,394 @@
# Design Document: Blog SSG Optimization
## Overview
این طراحی یک سیستم کامل Static Site Generation برای بلاگ را پیاده‌سازی می‌کند که تمام صفحات بلاگ را در زمان build به صورت استاتیک تولید می‌کند. این رویکرد performance، SEO و قابلیت استقرار را بهبود می‌دهد.
## Architecture
### High-Level Architecture
```
Build Time:
┌─────────────────────────────────────────────────────────┐
│ Nuxt Build Process │
│ │
│ ┌──────────────┐ ┌─────────────────┐ │
│ │ Content │─────▶│ Route │ │
│ │ Discovery │ │ Generator │ │
│ └──────────────┘ └─────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────┐ │
│ │ │ Pre-renderer │ │
│ │ └─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌─────────────────┐ │
│ │ Sitemap │ │ Static HTML │ │
│ │ Generator │ │ Files │ │
│ └──────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
┌──────────────────┐
│ .output/public │
│ (Static Files) │
└──────────────────┘
```
### Runtime Architecture
```
User Request ──▶ CDN/Static Host ──▶ Pre-rendered HTML
(No Server Required)
```
## Components and Interfaces
### 1. Nitro Prerender Configuration
**Purpose:** پیکربندی Nitro برای pre-rendering خودکار تمام مسیرهای بلاگ
**Location:** `nuxt.config.ts`
**Configuration:**
```typescript
nitro: {
prerender: {
crawlLinks: true,
routes: [
'/',
'/blog',
'/fa/blog'
]
}
}
```
**Key Features:**
- `crawlLinks: true` - خزیدن خودکار لینک‌ها برای کشف مسیرها
- مسیرهای seed برای شروع crawling
- پشتیبانی از چند زبانه (en/fa)
### 2. Dynamic Route Generator Hook
**Purpose:** تولید خودکار لیست تمام مسیرهای بلاگ برای pre-rendering
**Location:** `nuxt.config.ts` یا `server/plugins/prerender.ts`
**Implementation Strategy:**
از Nitro hook `prerender:routes` برای اضافه کردن مسیرهای دینامیک:
```typescript
// server/plugins/prerender.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('prerender:routes', async (ctx) => {
// Fetch all blog posts
const posts = await queryCollection('blog')
.where('draft', '<>', true)
.all()
// Generate routes for each post
for (const post of posts) {
ctx.routes.add(post.path)
}
})
})
```
**Benefits:**
- تشخیص خودکار تمام پست‌های بلاگ
- عدم نیاز به لیست دستی مسیرها
- پشتیبانی از draft posts (حذف از pre-render)
### 3. Sitemap Module Integration
**Purpose:** تولید خودکار sitemap.xml برای SEO
**Module:** `@nuxtjs/sitemap` یا `nuxt-simple-sitemap`
**Configuration:**
```typescript
// nuxt.config.ts
modules: [
'@nuxtjs/sitemap'
],
sitemap: {
hostname: 'https://aliarghyani.vercel.app',
gzip: true,
routes: async () => {
const posts = await queryCollection('blog')
.where('draft', '<>', true)
.all()
return posts.map(post => ({
url: post.path,
lastmod: post.updatedAt || post.date,
changefreq: 'monthly',
priority: 0.8
}))
}
}
```
**Output:**
- `/sitemap.xml` - sitemap اصلی
- شامل تمام پست‌های منتشر شده
- تاریخ آخرین تغییر برای هر URL
### 4. Build Script Optimization
**Purpose:** بهینه‌سازی فرآیند build برای SSG
**Location:** `package.json`
**Scripts:**
```json
{
"scripts": {
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview"
}
}
```
**Command Usage:**
- `pnpm generate` - تولید فایل‌های استاتیک کامل
- خروجی در `.output/public`
## Data Models
### Blog Post Route Structure
```typescript
interface BlogRoute {
path: string // e.g., "/blog/post-slug" or "/fa/blog/post-slug"
locale: 'en' | 'fa'
slug: string
lastmod: string // ISO 8601 date
priority: number // 0.0 to 1.0
}
```
### Prerender Context
```typescript
interface PrerenderContext {
routes: Set<string> // مجموعه مسیرهای برای pre-render
}
```
## Error Handling
### 1. Missing Content Files
**Scenario:** فایل markdown وجود ندارد
**Handling:**
- در زمان build، خطا نمایش داده شود
- Build process متوقف شود
- پیام خطای واضح برای developer
### 2. Invalid Frontmatter
**Scenario:** frontmatter پست بلاگ نامعتبر است
**Handling:**
- Validation در زمان build
- خطای واضح با نام فایل
- پیشنهاد فرمت صحیح
### 3. Broken Internal Links
**Scenario:** لینک داخلی به صفحه‌ای اشاره می‌کند که وجود ندارد
**Handling:**
- Warning در build logs
- ادامه build process
- لیست لینک‌های شکسته در انتهای build
### 4. Build Timeout
**Scenario:** pre-rendering زمان زیادی می‌برد
**Handling:**
- تنظیم timeout مناسب در Nitro config
- نمایش progress در console
- امکان افزایش timeout برای بلاگ‌های بزرگ
## Testing Strategy
### 1. Build Testing
**Objective:** اطمینان از موفقیت build process
**Tests:**
- اجرای `pnpm generate` و بررسی exit code
- بررسی وجود فایل‌های HTML در `.output/public`
- بررسی تعداد فایل‌های تولید شده
**Example:**
```bash
pnpm generate
# Check exit code
echo $? # Should be 0
# Check generated files
ls -la .output/public/blog/
ls -la .output/public/fa/blog/
```
### 2. Route Coverage Testing
**Objective:** اطمینان از pre-render تمام مسیرها
**Tests:**
- بررسی وجود HTML برای هر پست بلاگ
- بررسی صفحات index
- بررسی هر دو locale
**Example:**
```bash
# Check English blog posts
test -f .output/public/blog/index.html
test -f .output/public/blog/post-slug/index.html
# Check Persian blog posts
test -f .output/public/fa/blog/index.html
test -f .output/public/fa/blog/post-slug/index.html
```
### 3. Sitemap Validation
**Objective:** اطمینان از صحت sitemap
**Tests:**
- بررسی وجود `/sitemap.xml`
- Validation XML syntax
- بررسی تعداد URLها
- بررسی فرمت تاریخ‌ها
**Example:**
```bash
# Check sitemap exists
test -f .output/public/sitemap.xml
# Validate XML
xmllint --noout .output/public/sitemap.xml
```
### 4. Content Integrity Testing
**Objective:** اطمینان از صحت محتوای pre-rendered
**Tests:**
- بررسی وجود meta tags در HTML
- بررسی وجود محتوای کامل
- بررسی structured data (JSON-LD)
**Example:**
```bash
# Check meta tags
grep -q "og:title" .output/public/blog/post-slug/index.html
grep -q "application/ld+json" .output/public/blog/post-slug/index.html
```
### 5. Performance Testing
**Objective:** اندازه‌گیری بهبود performance
**Metrics:**
- زمان بارگذاری صفحه
- First Contentful Paint (FCP)
- Largest Contentful Paint (LCP)
- Time to Interactive (TTI)
**Tools:**
- Lighthouse CI
- WebPageTest
- Chrome DevTools
## Implementation Phases
### Phase 1: Basic SSG Setup
- پیکربندی Nitro prerender
- تست با چند پست نمونه
### Phase 2: Dynamic Route Generation
- پیاده‌سازی prerender hook
- تشخیص خودکار تمام پست‌ها
### Phase 3: Sitemap Integration
- نصب و پیکربندی sitemap module
- تولید sitemap با تمام مسیرها
### Phase 4: Optimization & Testing
- بهینه‌سازی build process
- تست کامل و validation
## Deployment Considerations
### Static Hosting Options
**Recommended Platforms:**
1. **Vercel** - بهترین گزینه برای Nuxt
2. **Netlify** - پشتیبانی عالی از SSG
3. **Cloudflare Pages** - سریع و رایگان
4. **GitHub Pages** - رایگان برای پروژه‌های عمومی
### Build Command
```bash
pnpm generate
```
### Output Directory
```
.output/public
```
### Environment Variables
```env
NUXT_PUBLIC_SITE_URL=https://aliarghyani.vercel.app
```
## Performance Expectations
### Before SSG (SSR)
- TTFB: 200-500ms
- FCP: 800-1200ms
- LCP: 1500-2500ms
### After SSG
- TTFB: 50-100ms (از CDN)
- FCP: 300-600ms
- LCP: 600-1200ms
**Expected Improvement:** 50-70% بهبود در زمان بارگذاری
## Maintenance
### Adding New Posts
1. اضافه کردن فایل markdown به `content/`
2. اجرای `pnpm generate`
3. Deploy فایل‌های جدید
### Updating Existing Posts
1. ویرایش فایل markdown
2. اجرای `pnpm generate`
3. Deploy مجدد
### No Server Maintenance Required
- نیازی به نگهداری سرور Node.js نیست
- فقط فایل‌های استاتیک
- کاهش هزینه‌های infrastructure
@@ -0,0 +1,83 @@
# Requirements Document
## Introduction
این سند نیازمندی‌های پیاده‌سازی کامل Static Site Generation (SSG) برای بلاگ را مشخص می‌کند. هدف اصلی بهبود performance، SEO و کاهش هزینه‌های هاستینگ از طریق pre-rendering تمام صفحات بلاگ در زمان build است.
## Glossary
- **Blog_System**: سیستم مدیریت و نمایش محتوای بلاگ در اپلیکیشن Nuxt
- **SSG (Static Site Generation)**: فرآیند تولید فایل‌های HTML استاتیک در زمان build
- **Pre-rendering**: تولید HTML از قبل برای صفحات در زمان build
- **Nuxt_Content**: ماژول Nuxt برای مدیریت محتوای markdown
- **Sitemap**: فایل XML حاوی لیست تمام URLهای سایت برای موتورهای جستجو
- **Build_Process**: فرآیند تبدیل کد منبع به فایل‌های قابل استقرار
## Requirements
### Requirement 1
**User Story:** به عنوان یک کاربر، می‌خواهم صفحات بلاگ با سرعت بالا بارگذاری شوند تا تجربه کاربری بهتری داشته باشم
#### Acceptance Criteria
1. WHEN a user navigates to any blog post, THE Blog_System SHALL serve a pre-rendered HTML file
2. WHEN a user navigates to the blog index page, THE Blog_System SHALL serve a pre-rendered HTML file
3. THE Blog_System SHALL generate all blog routes during the Build_Process
4. THE Blog_System SHALL include both English and Persian blog routes in pre-rendering
### Requirement 2
**User Story:** به عنوان یک توسعه‌دهنده، می‌خواهم تمام مسیرهای بلاگ به صورت خودکار شناسایی و pre-render شوند تا نیازی به مدیریت دستی نباشد
#### Acceptance Criteria
1. THE Blog_System SHALL automatically discover all markdown files in the content directory during Build_Process
2. THE Blog_System SHALL generate routes for all discovered blog posts in both locales
3. WHEN new blog posts are added to the content directory, THE Blog_System SHALL include them in the next Build_Process
4. THE Blog_System SHALL exclude draft posts from pre-rendering
### Requirement 3
**User Story:** به عنوان یک مدیر سایت، می‌خواهم sitemap خودکار تولید شود تا SEO بهتری داشته باشم
#### Acceptance Criteria
1. THE Blog_System SHALL generate an XML sitemap during Build_Process
2. THE Blog_System SHALL include all published blog posts in the sitemap
3. THE Blog_System SHALL include both English and Persian URLs in the sitemap
4. THE Blog_System SHALL include lastmod dates for each URL in the sitemap
5. THE Blog_System SHALL exclude draft posts from the sitemap
### Requirement 4
**User Story:** به عنوان یک توسعه‌دهنده، می‌خواهم فرآیند build بهینه باشد تا زمان deployment کاهش یابد
#### Acceptance Criteria
1. THE Blog_System SHALL use efficient crawling strategies to discover routes
2. THE Blog_System SHALL cache unchanged pages during Build_Process where possible
3. THE Blog_System SHALL provide clear build logs showing pre-rendered routes
4. WHEN the Build_Process completes, THE Blog_System SHALL output all generated static files to the dist directory
### Requirement 5
**User Story:** به عنوان یک کاربر، می‌خواهم محتوای بلاگ برای موتورهای جستجو قابل دسترسی باشد تا بتوانم مطالب را از طریق جستجو پیدا کنم
#### Acceptance Criteria
1. THE Blog_System SHALL include complete HTML content in pre-rendered pages
2. THE Blog_System SHALL include proper meta tags in pre-rendered pages
3. THE Blog_System SHALL include structured data (JSON-LD) in pre-rendered pages
4. THE Blog_System SHALL ensure all internal links are functional in static output
### Requirement 6
**User Story:** به عنوان یک توسعه‌دهنده، می‌خواهم بتوانم سایت را روی هر CDN یا static hosting استقرار دهم
#### Acceptance Criteria
1. THE Blog_System SHALL generate a fully static output compatible with static hosting services
2. THE Blog_System SHALL not require a Node.js server for serving blog pages
3. THE Blog_System SHALL include all necessary assets in the static output
4. THE Blog_System SHALL generate proper fallback pages for 404 errors
@@ -0,0 +1,78 @@
# Implementation Plan
- [x] 1. Install and configure sitemap module
- Install `nuxt-simple-sitemap` package
- Add module to `nuxt.config.ts`
- Configure basic sitemap settings with site URL
- _Requirements: 3.1, 3.2, 3.3_
- [x] 2. Create dynamic route generator for blog posts
- Create `server/plugins/prerender.ts` file
- Implement Nitro hook to discover all blog posts
- Add all non-draft blog post routes to prerender context
- Handle both English and Persian locales
- _Requirements: 2.1, 2.2, 2.3, 2.4_
- [ ] 3. Configure Nitro prerender settings
- Update `nitro.prerender` configuration in `nuxt.config.ts`
- Enable `crawlLinks` for automatic link discovery
- Add seed routes for blog index pages (`/blog`, `/fa/blog`)
- Configure prerender to exclude draft posts
- _Requirements: 1.3, 1.4, 4.1_
- [ ] 4. Implement sitemap dynamic routes
- Configure sitemap module to fetch blog posts dynamically
- Map blog posts to sitemap entries with proper metadata
- Include `lastmod`, `changefreq`, and `priority` for each entry
- Ensure draft posts are excluded from sitemap
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
- [ ] 5. Update build configuration
- Verify `nuxt generate` command in `package.json`
- Add environment variable for site URL if needed
- Document build process in README or comments
- _Requirements: 4.4, 6.1, 6.3_
- [x] 6. Test SSG build process
- Run `pnpm generate` command
- Verify all blog post HTML files are generated in `.output/public`
- Check both English and Persian blog routes
- Verify sitemap.xml is generated
- _Requirements: 1.1, 1.2, 4.3, 4.4_
- [ ]* 7. Validate generated output
- Check meta tags in generated HTML files
- Verify structured data (JSON-LD) is present
- Test internal links functionality
- Validate sitemap XML syntax
- _Requirements: 5.1, 5.2, 5.3, 5.4_
- [ ]* 8. Performance testing
- Measure page load times before and after SSG
- Run Lighthouse audit on generated pages
- Document performance improvements
- _Requirements: 1.1, 1.2_
- [x] 9. Update deployment documentation
- Document the `pnpm generate` command for deployment
- Specify output directory (`.output/public`)
- List compatible static hosting platforms
- Add environment variables needed for production
- _Requirements: 6.1, 6.2, 6.4_
+595
View File
@@ -0,0 +1,595 @@
# i18n Routing Fixes Design Document
## Overview
This document outlines the technical design for fixing i18n routing and hydration issues in the Nuxt 4 portfolio application. The issues include Vue Router warnings during language switching, hydration mismatches in the Footer component, and accessibility warnings in the language switcher.
### Current Issues
1. **Vue Router Warnings**: When switching languages, Vue Router cannot find blog post routes with language prefixes
2. **Hydration Mismatch**: Footer component causes hydration errors due to colorMode access during SSR
3. **ARIA Warning**: Language switcher has aria-hidden on focusable elements
4. **Route Resolution**: Blog routes with `/en/` or `/fa/` prefixes are not properly resolved
### Design Goals
1. **Fix Route Resolution**: Ensure all blog routes work correctly with language prefixes
2. **Eliminate Hydration Errors**: Make Footer component SSR-safe
3. **Improve Accessibility**: Fix ARIA warnings in language switcher
4. **Maintain User Experience**: Keep smooth language switching without breaking functionality
## Architecture
### Current i18n Configuration
```typescript
// nuxt.config.ts
i18n: {
defaultLocale: 'en',
strategy: 'prefix_except_default', // ← This is the issue!
locales: [
{ code: 'en', language: 'en-US', name: 'English', dir: 'ltr', file: 'en.json' },
{ code: 'fa', language: 'fa-IR', name: 'فارسی', dir: 'rtl', file: 'fa.json' },
],
// ...
}
```
**Problem**: The `prefix_except_default` strategy means:
- English routes: `/blog/post-slug` (no prefix)
- Persian routes: `/fa/blog/post-slug` (with prefix)
This causes issues because:
1. Content is organized as `content/en/blog/` and `content/fa/blog/`
2. When switching languages, the router looks for `/en/blog/` routes that don't exist
3. Blog navigation uses `localePath()` which generates inconsistent paths
### Root Cause Analysis
#### Issue 1: Route Strategy Mismatch
**Current Behavior**:
- Content structure: `content/{locale}/blog/`
- Route strategy: `prefix_except_default` (English has no prefix)
- Blog queries: `queryContent('${locale}/blog')`
**Problem**: When on `/blog/post` (English) and switching to Persian, the app tries to navigate to `/fa/blog/post`, but the content query still uses the old locale path.
**Solution**: Change strategy to `prefix` so all routes have locale prefixes consistently.
#### Issue 2: Hydration Mismatch in Footer
**Current Code**:
```vue
<script setup lang="ts">
const colorMode = useColorMode()
const logoSrc = computed(() => {
if (colorMode.unknown) {
return '/favicon/android-chrome-192x192.png'
}
return colorMode.value === 'dark'
? '/favicon/android-chrome-192x192-dark.png'
: '/favicon/android-chrome-192x192.png'
})
</script>
```
**Problem**:
- Server renders with `colorMode.unknown = true` (default)
- Client hydrates with actual colorMode from localStorage
- HTML mismatch causes hydration error
**Solution**: Use `ClientOnly` for colorMode-dependent content or defer rendering until mounted.
#### Issue 3: ARIA Warning in Language Switcher
**Current Code**:
```vue
<template>
<USelect
v-model="model"
:items="items"
aria-label="Language selector"
:ui="{ value: 'sr-only' }"
>
<!-- ... -->
</USelect>
</template>
```
**Problem**: The `sr-only` class likely adds `aria-hidden="true"` to focusable elements, which is invalid.
**Solution**: Remove `sr-only` from focusable elements and use proper ARIA labels instead.
## Components and Interfaces
### 1. i18n Configuration Update
**File**: `nuxt.config.ts`
**Changes**:
```typescript
i18n: {
defaultLocale: 'en',
strategy: 'prefix', // ← Change from 'prefix_except_default'
locales: [
{ code: 'en', language: 'en-US', name: 'English', dir: 'ltr', file: 'en.json' },
{ code: 'fa', language: 'fa-IR', name: 'فارسی', dir: 'rtl', file: 'fa.json' },
],
langDir: 'locales',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_redirected',
alwaysRedirect: false,
redirectOn: 'root'
},
vueI18n: '~/i18n.config.ts'
}
```
**Impact**:
- All routes will have locale prefix: `/en/`, `/fa/`
- Root `/` will redirect to `/en/` (default locale)
- Consistent URL structure across all pages
- Blog routes: `/en/blog/post` and `/fa/blog/post`
**Migration Notes**:
- Update all internal links to use `localePath()`
- Update sitemap generation
- Update prerender routes
- Test all navigation flows
### 2. Footer Component Fix
**File**: `app/components/common/FooterCopyright.vue`
**Current Implementation**:
```vue
<template>
<footer class="py-10">
<UContainer>
<div class="flex flex-col items-center gap-4">
<NuxtImg :src="logoSrc" alt="Ali Arghyani logo" />
<!-- ... -->
</div>
</UContainer>
</footer>
</template>
<script setup lang="ts">
const colorMode = useColorMode()
const logoSrc = computed(() => {
if (colorMode.unknown) {
return '/favicon/android-chrome-192x192.png'
}
return colorMode.value === 'dark'
? '/favicon/android-chrome-192x192-dark.png'
: '/favicon/android-chrome-192x192.png'
})
</script>
```
**Solution 1: Use ClientOnly (Recommended)**:
```vue
<template>
<footer class="py-10">
<UContainer>
<div class="flex flex-col items-center gap-4">
<ClientOnly>
<NuxtImg :src="logoSrc" alt="Ali Arghyani logo" />
<template #fallback>
<NuxtImg src="/favicon/android-chrome-192x192.png" alt="Ali Arghyani logo" />
</template>
</ClientOnly>
<!-- ... -->
</div>
</UContainer>
</footer>
</template>
<script setup lang="ts">
const colorMode = useColorMode()
const logoSrc = computed(() => {
return colorMode.value === 'dark'
? '/favicon/android-chrome-192x192-dark.png'
: '/favicon/android-chrome-192x192.png'
})
</script>
```
**Solution 2: Use onMounted (Alternative)**:
```vue
<template>
<footer class="py-10">
<UContainer>
<div class="flex flex-col items-center gap-4">
<NuxtImg :src="logoSrc" alt="Ali Arghyani logo" />
<!-- ... -->
</div>
</UContainer>
</footer>
</template>
<script setup lang="ts">
const colorMode = useColorMode()
const isMounted = ref(false)
onMounted(() => {
isMounted.value = true
})
const logoSrc = computed(() => {
if (!isMounted.value) {
return '/favicon/android-chrome-192x192.png'
}
return colorMode.value === 'dark'
? '/favicon/android-chrome-192x192-dark.png'
: '/favicon/android-chrome-192x192.png'
})
</script>
```
**Recommendation**: Use Solution 1 (ClientOnly) as it's more explicit and follows Nuxt best practices.
### 3. Language Switcher Fix
**File**: `app/components/LanguageSwitcher.vue`
**Current Issues**:
1. `sr-only` class on value might cause ARIA conflicts
2. No proper route switching logic
3. Missing proper ARIA announcements
**Updated Implementation**:
```vue
<template>
<ClientOnly>
<USelect
v-model="model"
:items="items"
value-key="value"
size="sm"
color="primary"
variant="soft"
:highlight="false"
arrow
:trailing="true"
placeholder="Language"
class="px-1 w-[64px] sm:w-[76px] rounded-full ring-1 ring-gray-200/70 dark:ring-gray-700/60 backdrop-blur-md shadow-sm h-[25px]"
:ui="{
base: 'rounded-full',
trailingIcon: 'text-dimmed group-data-[state=open]:rotate-180 transition-transform duration-200',
content: 'min-w-fit'
}"
:aria-label="t('nav.languageSelector')"
>
<template #leading>
<UIcon :name="selectedIcon" class="text-[16px]" />
</template>
<template #item-leading="{ item }">
<UIcon :name="item.icon" class="text-[16px]" />
</template>
<template #item-label="{ item }">
<span>{{ item.label }}</span>
</template>
</USelect>
</ClientOnly>
</template>
<script setup lang="ts">
const { locale, setLocale } = useI18n()
const { t } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const router = useRouter()
type LangValue = 'en' | 'fa'
type Item = { label: string; value: LangValue; icon: string }
const items = ref<Item[]>([
{ label: 'English', value: 'en', icon: 'i-twemoji-flag-united-states' },
{ label: 'فارسی', value: 'fa', icon: 'i-twemoji-flag-iran' }
])
const model = ref<LangValue>(locale.value as LangValue)
// Keep model in sync if locale changes elsewhere
watch(locale, (val) => {
if ((val as LangValue) !== model.value) {
model.value = val as LangValue
}
})
const selectedIcon = computed<string>(() =>
items.value.find(i => i.value === model.value)?.icon ?? 'i-twemoji-flag-united-states'
)
const { startLocaleSwitching } = useLocaleSwitching()
const loading = useLoadingIndicator()
// On selection change, navigate to the equivalent page in the new locale
watch(model, async (val, oldVal) => {
if (val === oldVal) return
startLocaleSwitching(600)
if (loading) {
loading.start()
}
// Get the path for the new locale
const newPath = switchLocalePath(val)
// Navigate to the new path
await router.push(newPath)
// Update locale
await setLocale(val)
if (loading) {
setTimeout(() => loading.finish(), 600)
}
})
</script>
```
**Key Changes**:
1. Removed `sr-only` from UI config
2. Use `switchLocalePath()` to get the correct route for the new locale
3. Navigate using `router.push()` before setting locale
4. Added proper ARIA label using i18n
5. Changed item labels to full language names for better UX
### 4. Blog Navigation Updates
**Files to Update**:
- `app/pages/blog/index.vue`
- `app/pages/blog/[...slug].vue`
- `app/components/blog/BlogCard.vue`
- `app/components/blog/BlogNavigation.vue`
**Pattern to Follow**:
```vue
<script setup lang="ts">
const { locale } = useI18n()
const localePath = useLocalePath()
// Fetch posts for current locale
const { data: posts } = await useAsyncData('blog-posts', () =>
queryContent(`${locale.value}/blog`)
.where({ draft: { $ne: true } })
.sort({ date: -1 })
.find()
)
// Generate localized link
const postLink = computed(() => localePath(`/blog/${post.value._path.split('/').pop()}`))
</script>
```
**Important**: All blog links must use `localePath()` to ensure correct locale prefix.
## Data Models
### Route Structure
**Before (prefix_except_default)**:
```
/ → English home
/blog → English blog
/blog/post-slug → English post
/fa → Persian home
/fa/blog → Persian blog
/fa/blog/post-slug → Persian post
```
**After (prefix)**:
```
/ → Redirect to /en
/en → English home
/en/blog → English blog
/en/blog/post-slug → English post
/fa → Persian home
/fa/blog → Persian blog
/fa/blog/post-slug → Persian post
```
### Content Query Pattern
**Current**:
```typescript
queryContent(`${locale.value}/blog`)
```
**This remains the same** because content structure matches locale codes.
## Error Handling
### 404 Handling for Missing Translations
When a blog post exists in one language but not another:
```vue
<script setup lang="ts">
const { locale } = useI18n()
const route = useRoute()
const slug = route.params.slug as string[]
const { data: post } = await useAsyncData(`blog-post-${slug.join('/')}`, async () => {
try {
return await queryContent(`${locale.value}/blog`)
.where({ _path: `/${locale.value}/blog/${slug.join('/')}` })
.findOne()
} catch (error) {
return null
}
})
// If post not found, check if it exists in other locale
if (!post.value) {
const otherLocale = locale.value === 'en' ? 'fa' : 'en'
const { data: otherPost } = await useAsyncData(`blog-post-other-${slug.join('/')}`, async () => {
try {
return await queryContent(`${otherLocale}/blog`)
.where({ _path: `/${otherLocale}/blog/${slug.join('/')}` })
.findOne()
} catch (error) {
return null
}
})
if (otherPost.value) {
// Show message: "This post is only available in [other language]"
// Provide link to switch language
} else {
// Post doesn't exist in any language
throw createError({ statusCode: 404, message: 'Post not found' })
}
}
</script>
```
### Redirect Handling
**Root Path Redirect**:
```typescript
// middleware/redirect-root.global.ts
export default defineNuxtRouteMiddleware((to) => {
if (to.path === '/') {
return navigateTo('/en', { redirectCode: 301 })
}
})
```
## Testing Strategy
### Manual Testing Checklist
**i18n Routing**:
- [ ] Navigate to `/` → should redirect to `/en`
- [ ] Navigate to `/en` → should show English home
- [ ] Navigate to `/fa` → should show Persian home
- [ ] Navigate to `/en/blog` → should show English blog listing
- [ ] Navigate to `/fa/blog` → should show Persian blog listing
- [ ] Navigate to `/en/blog/post-slug` → should show English post
- [ ] Navigate to `/fa/blog/post-slug` → should show Persian post
**Language Switching**:
- [ ] On home page, switch from English to Persian → should navigate to `/fa`
- [ ] On home page, switch from Persian to English → should navigate to `/en`
- [ ] On blog listing, switch languages → should navigate to equivalent blog page
- [ ] On blog post, switch languages → should navigate to equivalent post (if exists)
- [ ] On blog post (only in one language), switch languages → should show fallback message
**Hydration**:
- [ ] Load page in light mode → no hydration errors in console
- [ ] Load page in dark mode → no hydration errors in console
- [ ] Switch color mode → logo updates correctly
- [ ] Check Footer logo on initial load → no flashing or mismatch
**Accessibility**:
- [ ] Language switcher is keyboard navigable (Tab, Enter, Arrow keys)
- [ ] Language switcher has proper ARIA labels
- [ ] No ARIA warnings in console
- [ ] Screen reader announces language changes
**Vue Router**:
- [ ] No Vue Router warnings in console during navigation
- [ ] No Vue Router warnings when switching languages
- [ ] Browser back/forward buttons work correctly
- [ ] URL updates correctly on language switch
### Browser Console Checks
**Before Fixes**:
```
❌ [Vue Router warn]: No match found for location with path "/en/blog/post-slug"
❌ Hydration mismatch in <img>
❌ [ARIA] aria-hidden should not be used on focusable elements
```
**After Fixes**:
```
✅ No Vue Router warnings
✅ No hydration warnings
✅ No ARIA warnings
```
## Performance Considerations
### Impact of Strategy Change
**Before (prefix_except_default)**:
- English routes: shorter URLs (no prefix)
- Persian routes: longer URLs (with prefix)
**After (prefix)**:
- All routes: consistent length (with prefix)
- Slightly longer URLs for English (adds 3 characters: `/en`)
**SEO Impact**:
- Minimal impact (3 characters)
- Better for international SEO (explicit language in URL)
- Easier for search engines to understand language variants
### Caching Strategy
Route rules remain the same:
```typescript
routeRules: {
'/en/blog': { swr: 3600 },
'/fa/blog': { swr: 3600 },
'/en/blog/**': { swr: 3600 },
'/fa/blog/**': { swr: 3600 }
}
```
## Migration Plan
### Step 1: Update i18n Configuration
- Change strategy from `prefix_except_default` to `prefix`
- Update prerender routes to include `/en` prefix
### Step 2: Fix Footer Component
- Wrap colorMode-dependent content in `ClientOnly`
- Add fallback for SSR
### Step 3: Fix Language Switcher
- Remove `sr-only` from UI config
- Implement proper route switching with `switchLocalePath()`
- Add proper ARIA labels
### Step 4: Update Blog Components
- Verify all blog links use `localePath()`
- Test blog navigation with new route structure
### Step 5: Add Redirect Middleware
- Create middleware to redirect `/` to `/en`
- Test redirect behavior
### Step 6: Update Route Rules
- Update route rules to use `/en` prefix
- Update sitemap generation
### Step 7: Testing
- Run manual testing checklist
- Verify no console errors
- Test all navigation flows
## Rollback Plan
If issues arise:
1. Revert `strategy` to `prefix_except_default` in `nuxt.config.ts`
2. Revert Footer component changes
3. Revert Language Switcher changes
4. Clear browser cache and cookies
5. Restart dev server
## Summary
This design addresses all three main issues:
1. **Vue Router Warnings**: Fixed by changing i18n strategy to `prefix` for consistent route structure
2. **Hydration Mismatch**: Fixed by wrapping colorMode-dependent content in `ClientOnly`
3. **ARIA Warning**: Fixed by removing `sr-only` from focusable elements and using proper ARIA labels
The changes are minimal, focused, and maintain backward compatibility with the content structure. All blog functionality will continue to work with the new route structure.
@@ -0,0 +1,113 @@
# Requirements Document
## Introduction
This document specifies the requirements for fixing i18n routing and hydration issues in the Nuxt 4 portfolio application. The issues include Vue Router warnings when switching languages, hydration mismatches in the Footer component, and accessibility warnings in the language switcher.
## Glossary
- **i18n System**: The internationalization system using @nuxtjs/i18n module for bilingual support (English and Persian)
- **Hydration Mismatch**: A Vue.js error that occurs when server-rendered HTML doesn't match client-side rendered content
- **Vue Router**: The official router for Vue.js applications, integrated with Nuxt
- **Language Switcher**: The UI component that allows users to switch between English and Persian languages
- **Route Prefix**: The language code prefix in URLs (e.g., /en/blog or /fa/blog)
- **localePath**: A helper function from @nuxtjs/i18n that generates locale-aware paths
- **ColorMode**: The dark/light theme system using @nuxtjs/color-mode module
- **ARIA**: Accessible Rich Internet Applications attributes for accessibility
## Requirements
### Requirement 1: Fix Vue Router Warnings for Blog Routes
**User Story:** As a visitor switching languages, I want the blog routes to work correctly, so that I don't encounter navigation errors.
#### Acceptance Criteria
1. WHEN a visitor switches from English to Persian, THE i18n System SHALL correctly resolve blog post routes with /fa/ prefix
2. WHEN a visitor switches from Persian to English, THE i18n System SHALL correctly resolve blog post routes with /en/ prefix
3. THE i18n System SHALL use localePath() helper for all blog navigation links to maintain locale context
4. WHERE a blog post exists in the current language, THE i18n System SHALL navigate to the localized version
5. WHERE a blog post does not exist in the target language, THE i18n System SHALL display a fallback message or redirect to the available version
6. THE i18n System SHALL not generate Vue Router warnings in the browser console during language switching
7. THE i18n System SHALL maintain the current page context when switching languages (e.g., stay on blog listing when switching from /blog to /fa/blog)
### Requirement 2: Fix Hydration Mismatch in Footer Component
**User Story:** As a visitor loading the page, I want the Footer to render without hydration errors, so that I have a smooth initial page load experience.
#### Acceptance Criteria
1. WHEN the page initially loads, THE Footer Component SHALL render identical HTML on server and client
2. THE Footer Component SHALL handle colorMode state without causing hydration mismatches
3. WHERE colorMode is accessed during SSR, THE Footer Component SHALL use a consistent default value
4. THE Footer Component SHALL update colorMode-dependent content only after hydration is complete
5. THE Footer Component SHALL not generate hydration mismatch warnings in the browser console
6. THE Footer Component SHALL display correctly in both light and dark modes after hydration
### Requirement 3: Fix ARIA Accessibility Warning in Language Switcher
**User Story:** As a visitor using assistive technology, I want the language switcher to be properly accessible, so that I can switch languages without accessibility issues.
#### Acceptance Criteria
1. THE Language Switcher SHALL not use aria-hidden on focusable or interactive elements
2. WHERE aria-hidden is used, THE Language Switcher SHALL ensure the element is not focusable
3. THE Language Switcher SHALL provide appropriate ARIA labels for screen readers
4. THE Language Switcher SHALL use semantic HTML for language selection
5. THE Language Switcher SHALL be keyboard navigable (Tab, Enter, Space keys)
6. THE Language Switcher SHALL announce language changes to screen readers
7. THE Language Switcher SHALL not generate ARIA-related warnings in the browser console
### Requirement 4: Improve i18n Route Configuration
**User Story:** As a developer, I want the i18n routing configuration to be correct, so that all routes work properly with language prefixes.
#### Acceptance Criteria
1. THE i18n System SHALL configure route prefixes correctly in nuxt.config.ts
2. THE i18n System SHALL use strategy: 'prefix' or 'prefix_except_default' for consistent URL structure
3. THE i18n System SHALL define all routes with proper locale prefixes
4. THE i18n System SHALL handle dynamic routes (like blog slugs) with locale awareness
5. THE i18n System SHALL provide fallback routes when content is not available in a locale
6. THE i18n System SHALL generate correct sitemap with all localized routes
### Requirement 5: Ensure Consistent Client-Server Rendering
**User Story:** As a visitor, I want the page to load without visual flashes or content shifts, so that I have a smooth browsing experience.
#### Acceptance Criteria
1. WHEN the page loads, THE Application SHALL render identical content on server and client
2. THE Application SHALL defer client-only content until after hydration using ClientOnly component
3. WHERE dynamic content depends on browser APIs, THE Application SHALL use onMounted lifecycle hook
4. THE Application SHALL not cause layout shifts during hydration
5. THE Application SHALL handle localStorage and cookies consistently between server and client
6. THE Application SHALL not generate Suspense-related warnings during hydration
### Requirement 6: Fix Language Switcher Implementation
**User Story:** As a visitor, I want to switch languages smoothly, so that I can view content in my preferred language.
#### Acceptance Criteria
1. WHEN a visitor clicks the language switcher, THE Application SHALL navigate to the equivalent page in the target language
2. THE Language Switcher SHALL use switchLocalePath() helper from @nuxtjs/i18n
3. THE Language Switcher SHALL maintain the current route context (e.g., /blog/post-slug becomes /fa/blog/post-slug)
4. WHERE the current page doesn't exist in the target language, THE Language Switcher SHALL navigate to the home page of that locale
5. THE Language Switcher SHALL update the HTML lang attribute
6. THE Language Switcher SHALL update the document direction (ltr/rtl) for Persian
7. THE Language Switcher SHALL provide visual feedback during language switching
### Requirement 7: Testing and Validation
**User Story:** As a developer, I want to verify that all i18n and routing issues are resolved, so that I can ensure a quality user experience.
#### Acceptance Criteria
1. THE Application SHALL pass manual testing for language switching on all pages
2. THE Application SHALL not generate any hydration warnings in the browser console
3. THE Application SHALL not generate any Vue Router warnings in the browser console
4. THE Application SHALL not generate any ARIA accessibility warnings in the browser console
5. THE Application SHALL maintain proper URL structure with locale prefixes
6. THE Application SHALL handle browser back/forward navigation correctly with localized routes
7. THE Application SHALL work correctly in both development and production builds
+101
View File
@@ -0,0 +1,101 @@
# Implementation Plan
This implementation plan breaks down the i18n routing and hydration fixes into discrete, actionable coding tasks. Each task builds incrementally on previous work, with all code integrated and functional at each step.
## Task List
- [x] 1. Update i18n configuration to use prefix strategy
- Change strategy from 'prefix_except_default' to 'prefix' in nuxt.config.ts
- Update prerender routes to include /en prefix (/en/blog instead of /blog)
- Update route rules to use /en prefix for caching
- Verify configuration by checking generated routes in dev mode
- _Requirements: 1.1, 1.2, 1.3, 4.1, 4.2, 4.3_
- [x] 2. Create redirect middleware for root path
- Create middleware/redirect-root.global.ts file
- Implement redirect from / to /en with 301 status code
- Test redirect behavior in browser
- _Requirements: 4.5_
- [x] 3. Fix Footer component hydration mismatch
- Wrap NuxtImg with colorMode-dependent src in ClientOnly component
- Add fallback template with default logo for SSR
- Remove colorMode.unknown check (no longer needed)
- Test in both light and dark modes
- Verify no hydration warnings in console
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 5.1, 5.2_
- [ ] 4. Fix Language Switcher ARIA and routing issues
- Remove 'sr-only' from :ui config in LanguageSwitcher.vue
- Add proper aria-label using i18n translation key
- Import and use switchLocalePath() composable
- Update watch logic to use switchLocalePath() for route generation
- Navigate to new path using router.push() before setLocale()
- Change item labels from 'en'/'fa' to 'English'/'فارسی' for better UX
- Test language switching on all pages (home, blog listing, blog post)
- Verify no ARIA warnings in console
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7_
- [ ] 5. Update TopNav component for new route structure
- Verify all navigation links use localePath() helper
- Update blog link to use localePath('/blog')
- Update home navigation to use localePath('/')
- Test navigation from all sections
- _Requirements: 1.3, 4.4_
- [ ] 6. Verify blog components use localePath correctly
- Check BlogCard.vue uses localePath for post links
- Check BlogNavigation.vue uses localePath for prev/next links
- Check blog/index.vue uses localePath for navigation
- Check blog/[...slug].vue uses localePath for breadcrumbs and back link
- Fix any hardcoded paths to use localePath()
- _Requirements: 1.3, 1.4, 6.3_
- [ ] 7. Add i18n translation key for language selector
- Add 'nav.languageSelector' key to i18n/locales/en.json
- Add 'nav.languageSelector' key to i18n/locales/fa.json
- _Requirements: 3.3_
- [ ] 8. Test and verify all fixes
- Test root path redirect (/ → /en)
- Test language switching on home page
- Test language switching on blog listing page
- Test language switching on blog post page
- Test browser back/forward navigation
- Verify no hydration warnings in console
- Verify no Vue Router warnings in console
- Verify no ARIA warnings in console
- Test in both light and dark modes
- Test keyboard navigation for language switcher
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7_
- [ ]* 9. Add fallback handling for missing translations
- In blog/[...slug].vue, add logic to check if post exists in other locale
- Display message when post is only available in other language
- Provide link to switch to the language where post exists
- Test with posts that exist in only one language
- _Requirements: 1.5, 4.5_
- [ ]* 10. Update sitemap generation for new route structure
- Update sitemap configuration to include /en prefix
- Verify all routes are included in sitemap
- Test sitemap generation in build
- _Requirements: 4.6_
+226
View File
@@ -0,0 +1,226 @@
# Design Document: Layout Refactoring
## Overview
این طراحی یک refactoring ساده ولی مهم برای جابجایی کامپوننت‌های مشترک UI از `app.vue` به `default.vue` layout است. هدف اصلی پیروی از معماری استاندارد Nuxt و جداسازی concerns است.
### Current Architecture Problems
1. **Mixing Concerns**: `app.vue` هم global configuration و هم specific UI components دارد
2. **Poor Reusability**: اگر بخواهیم صفحه‌ای بدون TopNav داشته باشیم، امکان‌پذیر نیست
3. **Not Following Nuxt Conventions**: Nuxt layout system برای همین موارد طراحی شده ولی استفاده نمی‌شود
### Proposed Solution
جابجایی TopNav و FooterCopyright به `default.vue` layout و تمیز کردن `app.vue` به یک wrapper خالص.
## Architecture
### File Structure
```
app/
├── app.vue # Global wrapper (cleaned)
├── layouts/
│ ├── default.vue # Main layout with TopNav + Footer (updated)
│ └── marketing.vue # Preserved for future use
├── components/
│ └── common/
│ ├── TopNav.vue # No changes needed
│ └── FooterCopyright.vue # No changes needed
└── pages/
├── index.vue # Uses default layout automatically
└── blog/
├── index.vue # Uses default layout automatically
└── [...slug].vue # Uses default layout automatically
```
### Component Hierarchy
**Before:**
```
UApp (app.vue)
├── NuxtLoadingIndicator
├── TopNav
├── NuxtPage
│ └── Page Content
└── FooterCopyright
```
**After:**
```
UApp (app.vue)
├── NuxtLoadingIndicator
└── NuxtLayout (default)
├── TopNav
├── NuxtPage
│ └── Page Content
└── FooterCopyright
```
## Components and Interfaces
### 1. app.vue (Refactored)
**Purpose**: Global application wrapper با configuration و global components
**Structure**:
```vue
<template>
<UApp :toaster="{ expand: false }">
<NuxtLoadingIndicator />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>
```
**Responsibilities**:
- Global app configuration (UApp, toaster)
- Loading indicator
- Head management (fonts, meta tags)
- Language and direction attributes
- Layout wrapper
**What to Remove**:
- TopNav component import and usage
- FooterCopyright component import and usage
- FloatingActions unused import
### 2. layouts/default.vue (Updated)
**Purpose**: Main layout برای صفحات اصلی و بلاگ
**Structure**:
```vue
<template>
<div class="layout-default">
<TopNav />
<slot />
<FooterCopyright />
</div>
</template>
```
**Responsibilities**:
- Render TopNav
- Provide slot for page content
- Render FooterCopyright
- Maintain proper spacing and structure
**Styling Considerations**:
- باید مطمئن شویم که spacing بین TopNav و content حفظ می‌شود
- TopNav از قبل `fixed` positioning دارد، پس نیازی به تغییر نیست
- ممکن است نیاز به `padding-top` برای content باشد تا زیر TopNav نرود
### 3. TopNav.vue (No Changes)
این کامپوننت تغییری نمی‌کند چون:
- خودش `fixed positioning` دارد
- مستقل از parent خودش کار می‌کند
- هیچ dependency به app.vue ندارد
### 4. FooterCopyright.vue (No Changes)
این کامپوننت هم تغییری نمی‌کند.
## Data Models
هیچ data model جدیدی نیاز نیست. این یک refactoring ساختاری است.
## Error Handling
### Potential Issues
1. **Layout Not Applied**: اگر Nuxt به درستی default layout را تشخیص ندهد
- **Solution**: اضافه کردن explicit layout declaration در nuxt.config.ts یا pages
2. **Styling Breaks**: ممکن است spacing یا positioning مشکل پیدا کند
- **Solution**: بررسی دقیق visual regression و اضافه کردن padding/margin در صورت نیاز
3. **Client-Side Hydration Mismatch**: اگر TopNav در layout و app.vue تفاوت داشته باشد
- **Solution**: حذف کامل TopNav از app.vue قبل از اضافه کردن به layout
## Migration Strategy
### Step-by-Step Approach
1. **Update default.vue layout**
- Import TopNav و FooterCopyright
- Add components to template
- Test visual appearance
2. **Update app.vue**
- Remove TopNav و FooterCopyright imports
- Remove components from template
- Add NuxtLayout wrapper
- Keep all head management and global config
3. **Verify pages work correctly**
- Test homepage
- Test blog index
- Test blog post pages
- Check navigation between pages
4. **Clean up unused code**
- Remove FloatingActions unused import
- Remove isLocaleSwitching unused variable
## Testing Strategy
### Manual Testing Checklist
1. **Visual Regression**
- [ ] Homepage looks identical
- [ ] Blog index looks identical
- [ ] Blog post pages look identical
- [ ] TopNav positioning is correct
- [ ] Footer positioning is correct
2. **Functionality**
- [ ] TopNav navigation works (hero, skills, work, projects)
- [ ] Blog link works
- [ ] Language switcher works
- [ ] Theme switcher works
- [ ] Responsive behavior works on mobile
3. **Navigation**
- [ ] Navigate from home to blog
- [ ] Navigate from blog to home
- [ ] Navigate between blog posts
- [ ] TopNav and Footer persist correctly
4. **Performance**
- [ ] No hydration errors in console
- [ ] No layout shift (CLS)
- [ ] Loading indicator works
### Browser Testing
- Chrome/Edge (desktop & mobile)
- Firefox
- Safari (if available)
## Performance Considerations
این refactoring نباید تأثیر منفی روی performance داشته باشد:
- **Bundle Size**: تغییری نمی‌کند (فقط جابجایی کد)
- **Rendering**: ممکن است کمی بهتر شود چون Nuxt layout caching دارد
- **Hydration**: باید مشابه قبل باشد
## Future Enhancements
بعد از این refactoring، می‌توانیم:
1. **Create Blog-Specific Layout**: اگر بخواهیم blog layout متفاوتی داشته باشیم
2. **Create Clean Layout**: برای صفحاتی که نیاز به TopNav ندارند (مثل login, 404)
3. **Add Layout Transitions**: انیمیشن بین layout های مختلف
4. **Conditional Footer**: نمایش footer های متفاوت بر اساس صفحه
## References
- [Nuxt Layouts Documentation](https://nuxt.com/docs/guide/directory-structure/layouts)
- [Nuxt app.vue Documentation](https://nuxt.com/docs/guide/directory-structure/app)
- [Vue Slot Documentation](https://vuejs.org/guide/components/slots.html)
@@ -0,0 +1,60 @@
# Requirements Document
## Introduction
این سند الزامات refactoring ساختار layout در پروژه Nuxt را مشخص می‌کند. هدف اصلی جابجایی کامپوننت‌های مشترک (TopNav و Footer) از `app.vue` به layout مناسب است تا از best practices Nuxt پیروی شود و قابلیت نگهداری و توسعه‌پذیری بهبود یابد.
## Glossary
- **Layout System**: سیستم مدیریت قالب‌های صفحه در Nuxt که امکان تعریف ساختارهای مشترک برای صفحات مختلف را فراهم می‌کند
- **TopNav**: کامپوننت نوار ناوبری بالای صفحه که در تمام صفحات نمایش داده می‌شود
- **FooterCopyright**: کامپوننت فوتر که اطلاعات کپی‌رایت را نمایش می‌دهد
- **app.vue**: فایل اصلی اپلیکیشن Nuxt که wrapper کلی برنامه است
- **Default Layout**: قالب پیش‌فرض که برای صفحات اصلی و بلاگ استفاده می‌شود
## Requirements
### Requirement 1
**User Story:** به عنوان توسعه‌دهنده، می‌خواهم ساختار layout پروژه را بر اساس best practices Nuxt سازماندهی کنم تا نگهداری و توسعه آینده آسان‌تر شود
#### Acceptance Criteria
1. THE Layout System SHALL move TopNav component from app.vue to default layout
2. THE Layout System SHALL move FooterCopyright component from app.vue to default layout
3. THE app.vue SHALL contain only global wrappers and configuration without specific UI components
4. THE Default Layout SHALL include TopNav, page content slot, and FooterCopyright in correct order
5. WHEN a page uses default layout, THE Layout System SHALL render TopNav and Footer automatically
### Requirement 2
**User Story:** به عنوان توسعه‌دهنده، می‌خواهم صفحات موجود به صورت خودکار از layout جدید استفاده کنند بدون اینکه نیاز به تغییرات دستی در هر صفحه باشد
#### Acceptance Criteria
1. THE Layout System SHALL set default layout as the fallback layout for all pages
2. THE Layout System SHALL ensure homepage uses default layout without explicit declaration
3. THE Layout System SHALL ensure blog pages use default layout without explicit declaration
4. WHEN no layout is specified in a page, THE Layout System SHALL apply default layout automatically
### Requirement 3
**User Story:** به عنوان توسعه‌دهنده، می‌خواهم layout های موجود (marketing) را حفظ کنم برای استفاده‌های آینده
#### Acceptance Criteria
1. THE Layout System SHALL preserve existing marketing layout without modifications
2. THE Layout System SHALL keep marketing layout available for future use
3. THE Layout System SHALL not break any existing layout functionality
### Requirement 4
**User Story:** به عنوان کاربر، می‌خواهم تجربه کاربری من بعد از refactoring دقیقاً مانند قبل باشد
#### Acceptance Criteria
1. THE Layout System SHALL maintain identical visual appearance after refactoring
2. THE Layout System SHALL preserve all navigation functionality
3. THE Layout System SHALL maintain all responsive behaviors
4. THE Layout System SHALL keep all animations and transitions working
5. WHEN user navigates between pages, THE Layout System SHALL display TopNav and Footer consistently
+40
View File
@@ -0,0 +1,40 @@
# Implementation Plan
- [x] 1. Update default.vue layout with TopNav and Footer
- Import TopNav and FooterCopyright components
- Add TopNav before the slot
- Add FooterCopyright after the slot
- Add appropriate wrapper div with proper spacing
- _Requirements: 1.1, 1.4_
- [x] 2. Refactor app.vue to use layout system
- Remove TopNav component import and usage
- Remove FooterCopyright component import and usage
- Remove unused FloatingActions import
- Remove unused isLocaleSwitching variable
- Wrap NuxtPage with NuxtLayout component
- Keep all head management and global configuration
- _Requirements: 1.1, 1.2, 1.3_
- [x] 3. Verify visual appearance and functionality
- Check homepage renders correctly with TopNav and Footer
- Check blog index page renders correctly
- Check blog post pages render correctly
- Verify TopNav navigation works (hero, skills, work, projects, blog)
- Verify language switcher functionality
- Verify theme switcher functionality
- Check responsive behavior on different screen sizes
- Verify no console errors or hydration warnings
- _Requirements: 2.1, 2.2, 2.3, 4.1, 4.2, 4.3, 4.4, 4.5_
+130
View File
@@ -0,0 +1,130 @@
# MVP Blog Status - Ready ✅
## Completed Core Features
### ✅ Content Management
- [x] Nuxt Content v3 configured and integrated
- [x] Content directory structure (en/blog, fa/blog)
- [x] Sample blog posts (4 posts: 2 English, 2 Persian)
- [x] Draft post filtering
- [x] TypeScript interfaces for BlogPost
### ✅ Blog Listing Page
- [x] Blog index page with content fetching
- [x] BlogCard component with cover images
- [x] Search functionality with debounce
- [x] Tag filtering with URL query params
- [x] Empty state component
- [x] Responsive grid layout
### ✅ Blog Detail Page
- [x] Dynamic slug-based routing
- [x] SEO meta tags and Open Graph
- [x] JSON-LD structured data
- [x] BlogPost metadata component
- [x] ContentRenderer with Prose styling
- [x] Table of Contents with active section tracking
- [x] Previous/Next navigation
- [x] Breadcrumb navigation
### ✅ Custom Components
- [x] ProseCode with copy functionality
- [x] BlogCallout (info, warning, success, error)
- [x] Alert component
- [x] Prose component styling in app.config.ts
### ✅ Internationalization
- [x] English and Persian translations
- [x] RTL support for Persian content
- [x] Locale-aware routing
- [x] Date formatting per locale
### ✅ Features
- [x] RSS feed generation (both locales)
- [x] Blog link in navigation
- [x] Reading time calculation
- [x] Tag aggregation and filtering
- [x] Search across title/description/tags
- [x] Route caching (SWR: 3600s)
- [x] Prerendering configuration
### ✅ Documentation
- [x] Content authoring guide (content/README.md)
- [x] Frontmatter schema documentation
- [x] MDC component usage examples
- [x] Best practices and workflow
## What's Working
1. **Blog Listing**: `/blog` and `/fa/blog` display all published posts
2. **Blog Detail**: `/blog/[slug]` renders individual posts with full features
3. **Search**: Real-time search with 300ms debounce
4. **Filtering**: Tag-based filtering with URL persistence
5. **Navigation**: Smooth navigation between posts
6. **RSS Feeds**: Available at `/blog/rss.xml` and `/fa/blog/rss.xml`
7. **SEO**: Complete meta tags and structured data
8. **RTL**: Proper RTL layout for Persian content
## Sample Posts
### English
- Getting Started with Nuxt Content
- TypeScript Best Practices
- Building Beautiful UIs with Nuxt UI
- Draft Post (hidden)
### Persian
- آشنایی با Nuxt Content
- Vue Composition API
- نکات کار با Tailwind CSS در پروژه‌های RTL
## Remaining Tasks (Optional for MVP)
These tasks are marked with `*` and are not required for MVP:
- [ ]* Performance optimization and testing (Task 12)
- [ ]* Accessibility testing and improvements (Task 13)
- [ ]* Cross-browser and responsive testing (Task 14)
## How to Test
1. **Start dev server**: `pnpm dev`
2. **Visit blog listing**: `http://localhost:3000/blog`
3. **Test search**: Type in search box
4. **Test filtering**: Click on tags
5. **Read a post**: Click on any blog card
6. **Test navigation**: Use prev/next buttons
7. **Test RSS**: Visit `/blog/rss.xml`
8. **Test Persian**: Visit `/fa/blog`
## Next Steps
The MVP is complete and ready for production. Optional improvements:
1. Add actual cover images to `public/img/blog/`
2. Run performance audits
3. Test accessibility with screen readers
4. Cross-browser testing
5. Add more blog posts
## Files Created/Modified
### New Files
- `app/components/content/BlogCallout.vue`
- `app/components/content/Alert.vue`
- `server/routes/blog/rss.xml.ts`
- `server/routes/fa/blog/rss.xml.ts`
- `content/README.md`
- `content/en/blog/nuxt-ui-components.md`
- `content/fa/blog/tailwind-rtl-tips.md`
- `public/img/blog/.gitkeep`
### Modified Files
- `nuxt.config.ts` (prerendering, route rules)
- `app/app.config.ts` (Prose styling)
- `app/components/common/TopNav.vue` (blog link)
- `.kiro/specs/nuxt-content-blog/tasks.md` (status updates)
## Conclusion
🎉 **MVP Blog is ready for production!** All core features are implemented, tested, and working correctly in both English and Persian.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,241 @@
# Requirements Document
## Introduction
This document specifies the requirements for implementing a fully-featured blog system in the Nuxt 4 portfolio application using Nuxt Content v3. The blog system will support bilingual content (English and Persian with RTL), markdown-based content management, SEO optimization, and seamless integration with the existing portfolio design system.
## Glossary
- **Blog System**: The complete blogging functionality including content management, rendering, listing, and navigation
- **Nuxt Content**: The official Nuxt module (@nuxt/content) for file-based content management with markdown support
- **Content Directory**: The file system location where markdown blog posts are stored (content/ folder)
- **Blog Post**: A single article written in markdown format with frontmatter metadata
- **Frontmatter**: YAML metadata at the top of markdown files containing post information (title, date, tags, etc.)
- **Blog Listing Page**: The main blog index page displaying all published posts
- **Blog Detail Page**: Individual post page rendering the full markdown content
- **Content Query**: Nuxt Content's API for fetching and filtering markdown content
- **MDC Syntax**: Markdown Components syntax for embedding Vue components in markdown
- **SEO Metadata**: Meta tags, Open Graph, and structured data for search engine optimization
- **Reading Time**: Calculated estimate of time required to read a blog post
- **Tag System**: Categorization mechanism using tags/labels for blog posts
- **Draft Mode**: Unpublished posts that are hidden from production but visible in development
## Requirements
### Requirement 1: Nuxt Content Module Integration
**User Story:** As a developer, I want to integrate Nuxt Content v3 into the existing Nuxt 4 application, so that I can manage blog content using markdown files.
#### Acceptance Criteria
1. WHEN the developer installs the @nuxt/content package, THE Blog System SHALL use version 3.x compatible with Nuxt 4
2. WHEN the nuxt.config.ts is updated, THE Blog System SHALL register @nuxt/content in the modules array before other content-dependent modules
3. THE Blog System SHALL create a content/ directory in the project root for storing markdown files
4. THE Blog System SHALL configure Nuxt Content with Shiki syntax highlighter for code blocks
5. THE Blog System SHALL enable markdown.mdc option to support Vue component embedding in markdown
6. WHEN the development server starts, THE Blog System SHALL successfully load and parse all markdown files with hot-reload support
### Requirement 2: Content Directory Structure
**User Story:** As a content creator, I want a well-organized content directory structure, so that I can easily manage bilingual blog posts.
#### Acceptance Criteria
1. THE Blog System SHALL create locale-based subdirectories (content/en/blog/ and content/fa/blog/)
2. WHEN queryContent() is called with a locale parameter, THE Blog System SHALL fetch content from the corresponding locale directory
3. THE Blog System SHALL support nested directories within blog folders for content organization (e.g., content/en/blog/tutorials/)
4. THE Blog System SHALL recognize markdown files with .md extension as valid blog posts
5. WHERE a blog post exists in one language but not another, THE Blog System SHALL display a fallback message with a link to the available language version
6. THE Blog System SHALL use the file name (slug) as the URL path segment for blog posts
### Requirement 3: Blog Post Frontmatter Schema
**User Story:** As a content creator, I want a standardized frontmatter schema for blog posts, so that all posts have consistent metadata.
#### Acceptance Criteria
1. THE Blog System SHALL require the following frontmatter fields: title, description, date, and tags
2. THE Blog System SHALL support optional frontmatter fields: image, author, draft, updatedAt, and head (for custom SEO)
3. THE Blog System SHALL define a TypeScript interface extending ParsedContent for type-safe frontmatter access
4. THE Blog System SHALL parse date field as ISO 8601 date string (YYYY-MM-DD or full ISO format)
5. THE Blog System SHALL accept tags as an array of strings for categorization
6. WHERE draft is set to true, THE Blog System SHALL exclude the post from queryContent results in production using where({ draft: { $ne: true } })
7. THE Blog System SHALL use the image field for Open Graph and Twitter Card meta tags
### Requirement 4: Blog Listing Page Implementation
**User Story:** As a visitor, I want to see a list of all published blog posts, so that I can browse available content.
#### Acceptance Criteria
1. WHEN a visitor navigates to /blog or /fa/blog, THE Blog System SHALL use queryContent() to fetch all published posts for the current locale path
2. THE Blog System SHALL sort blog posts by date field in descending order using .sort({ date: -1 })
3. THE Blog System SHALL display post title, description, formatted date, reading time estimate, and tags for each post card
4. WHEN a visitor clicks on a blog post card, THE Blog System SHALL navigate to the localized post detail page using the _path property
5. WHERE no published posts exist for a locale, THE Blog System SHALL display an empty state message with i18n translation
6. THE Blog System SHALL filter out draft posts using .where({ draft: { $ne: true } }) in production environment
7. THE Blog System SHALL calculate reading time from the body.children word count assuming 200 words per minute
8. THE Blog System SHALL use .only() to fetch only required fields (title, description, date, tags, _path, image) for performance
### Requirement 5: Blog Detail Page Implementation
**User Story:** As a visitor, I want to read the full content of a blog post, so that I can consume the article.
#### Acceptance Criteria
1. WHEN a visitor navigates to /blog/[slug] or /fa/blog/[slug], THE Blog System SHALL use ContentDoc component or queryContent().where({ _path: path }).findOne() to fetch the post
2. THE Blog System SHALL render markdown using ContentRenderer component with GitHub Flavored Markdown support
3. THE Blog System SHALL apply Shiki syntax highlighting to code blocks with theme matching the site's color mode
4. THE Blog System SHALL render post metadata (title, formatted date, reading time, tags) in a header section using Nuxt UI components
5. WHERE the requested slug does not exist, THE Blog System SHALL throw a 404 error using createError({ statusCode: 404 })
6. THE Blog System SHALL support MDC syntax (::component-name) for embedding Vue components within markdown
7. THE Blog System SHALL apply Prose components styling from Nuxt UI for consistent typography (ProseH1, ProseP, ProseCode, etc.)
8. THE Blog System SHALL auto-generate anchor links for all headings for easy section sharing
### Requirement 6: SEO and Meta Tags
**User Story:** As a content creator, I want proper SEO metadata for blog posts, so that they rank well in search engines.
#### Acceptance Criteria
1. THE Blog System SHALL use useContentHead() composable to auto-generate meta tags from frontmatter
2. THE Blog System SHALL use useSeoMeta() to set title in format "[Post Title] | Blog | [Site Name]"
3. THE Blog System SHALL generate Open Graph tags (og:title, og:description, og:image, og:type, og:url) from post frontmatter
4. WHERE an image field is specified in frontmatter, THE Blog System SHALL use it for og:image and twitter:image, otherwise use a default blog cover image
5. THE Blog System SHALL set og:type to "article" and include article:published_time and article:tag properties
6. THE Blog System SHALL generate Twitter Card meta tags with card type "summary_large_image"
7. THE Blog System SHALL allow custom head overrides via the head field in frontmatter for advanced SEO control
8. THE Blog System SHALL generate JSON-LD structured data for BlogPosting schema including author, datePublished, and headline
### Requirement 7: Tag Filtering System
**User Story:** As a visitor, I want to filter blog posts by tags, so that I can find content on specific topics.
#### Acceptance Criteria
1. THE Blog System SHALL extract all unique tags from published posts using a computed property that aggregates tags arrays
2. WHEN a visitor clicks on a tag, THE Blog System SHALL filter posts using queryContent().where({ tags: { $contains: selectedTag } })
3. THE Blog System SHALL update the URL query parameter (?tag=value) using useRoute() and navigateTo() when a tag is selected
4. WHEN a visitor clears the tag filter, THE Blog System SHALL remove the query parameter and display all posts
5. THE Blog System SHALL highlight the active tag using Nuxt UI's UBadge or UButton component with active state styling
6. THE Blog System SHALL read the tag query parameter on page load to maintain filter state on navigation or refresh
### Requirement 8: Responsive Design and Accessibility
**User Story:** As a visitor using any device, I want the blog to be fully responsive and accessible, so that I can read content comfortably.
#### Acceptance Criteria
1. THE Blog System SHALL render blog listing and detail pages responsively across mobile, tablet, and desktop viewports
2. THE Blog System SHALL maintain readability with appropriate font sizes and line heights for body text
3. THE Blog System SHALL ensure sufficient color contrast ratios for text and backgrounds (WCAG AA compliance)
4. THE Blog System SHALL support keyboard navigation for all interactive elements
5. THE Blog System SHALL provide appropriate ARIA labels and semantic HTML for screen readers
6. WHERE images are used in blog posts, THE Blog System SHALL require alt text for accessibility
### Requirement 9: RTL Support for Persian Content
**User Story:** As a Persian-speaking visitor, I want blog content to display correctly in RTL layout, so that I can read naturally.
#### Acceptance Criteria
1. WHEN a visitor views Persian blog content, THE Blog System SHALL apply RTL text direction to all content
2. THE Blog System SHALL mirror layout elements appropriately for RTL (navigation, spacing, alignment)
3. THE Blog System SHALL maintain LTR direction for code blocks and technical content within RTL posts
4. THE Blog System SHALL handle mixed LTR/RTL content gracefully (e.g., English words in Persian text)
5. THE Blog System SHALL apply RTL-appropriate typography and spacing rules
### Requirement 10: Performance Optimization
**User Story:** As a visitor, I want blog pages to load quickly, so that I have a smooth browsing experience.
#### Acceptance Criteria
1. THE Blog System SHALL use NuxtImg component for all images in markdown to enable automatic optimization
2. THE Blog System SHALL configure @nuxt/image to generate responsive srcsets and modern formats (webp, avif)
3. THE Blog System SHALL use .only() and .without() query modifiers to fetch minimal data for listing pages
4. THE Blog System SHALL leverage Nuxt Content's built-in caching for content queries in production
5. THE Blog System SHALL prerender all blog routes during build using nitro.prerender.routes configuration
6. THE Blog System SHALL lazy-load blog components using defineAsyncComponent where appropriate
7. THE Blog System SHALL achieve a Lighthouse performance score of 90+ for blog pages
### Requirement 11: Development Experience
**User Story:** As a developer, I want a smooth development experience when working with blog content, so that I can iterate quickly.
#### Acceptance Criteria
1. WHEN a markdown file is modified, THE Blog System SHALL use Nuxt Content's HMR to hot-reload content without full page refresh
2. THE Blog System SHALL include draft posts in queryContent results during development (process.dev check)
3. WHERE a markdown parsing error occurs, THE Blog System SHALL display the error overlay with file path and line number
4. THE Blog System SHALL define TypeScript interfaces for BlogPost extending ParsedContent for type-safe queries
5. THE Blog System SHALL use Nuxt Content's built-in content:list server endpoint for debugging available content
6. THE Blog System SHALL provide helpful console warnings when required frontmatter fields are missing
### Requirement 12: Table of Contents
**User Story:** As a visitor reading a long blog post, I want to see a table of contents, so that I can quickly navigate to specific sections.
#### Acceptance Criteria
1. THE Blog System SHALL extract table of contents from the body.toc property provided by Nuxt Content
2. WHERE a blog post has 3 or more headings, THE Blog System SHALL display a table of contents sidebar on desktop viewports
3. THE Blog System SHALL render TOC links using the heading id and text from body.toc.links array
4. WHEN a visitor clicks a TOC link, THE Blog System SHALL smooth-scroll to the corresponding heading
5. THE Blog System SHALL highlight the active section in TOC based on scroll position using IntersectionObserver
6. THE Blog System SHALL hide the TOC on mobile viewports and show it as a collapsible section instead
7. THE Blog System SHALL support nested heading levels (h2, h3) in the TOC structure
### Requirement 13: Search Functionality
**User Story:** As a visitor, I want to search through blog posts, so that I can quickly find content on specific topics.
#### Acceptance Criteria
1. THE Blog System SHALL provide a search input field on the blog listing page using UInput component
2. WHEN a visitor types in the search field, THE Blog System SHALL filter posts using queryContent().where({ $or: [{ title: { $icontains: query } }, { description: { $icontains: query } }] })
3. THE Blog System SHALL debounce search input by 300ms to avoid excessive queries
4. THE Blog System SHALL display search results count and clear button when search is active
5. THE Blog System SHALL highlight search terms in the results using text highlighting
6. WHERE no results match the search query, THE Blog System SHALL display a "No posts found" message with suggestions
7. THE Blog System SHALL combine search with tag filtering when both are active
### Requirement 14: RSS Feed Generation
**User Story:** As a visitor, I want to subscribe to the blog via RSS, so that I can receive updates on new posts.
#### Acceptance Criteria
1. THE Blog System SHALL generate an RSS feed at /blog/rss.xml for English posts
2. THE Blog System SHALL generate an RSS feed at /fa/blog/rss.xml for Persian posts
3. THE Blog System SHALL use a Nitro server route to dynamically generate RSS XML from queryContent results
4. THE Blog System SHALL include post title, description, link, pubDate, and guid in each RSS item
5. THE Blog System SHALL set proper Content-Type header (application/rss+xml) for RSS endpoints
6. THE Blog System SHALL include channel metadata (title, description, link, language) in the RSS feed
7. THE Blog System SHALL add a link to the RSS feed in the blog listing page header for discoverability
### Requirement 15: Code Block Enhancements
**User Story:** As a visitor reading technical blog posts, I want enhanced code blocks with copy functionality, so that I can easily use code examples.
#### Acceptance Criteria
1. THE Blog System SHALL display a "Copy" button on all code blocks using a custom ProseCode component
2. WHEN a visitor clicks the copy button, THE Blog System SHALL copy the code to clipboard and show a success feedback
3. THE Blog System SHALL display the programming language label on code blocks when specified in markdown
4. THE Blog System SHALL support line highlighting using Nuxt Content's code highlighting syntax (```js{1,3-5})
5. THE Blog System SHALL apply syntax highlighting theme that matches the current color mode (light/dark)
6. THE Blog System SHALL support filename display for code blocks using custom metadata (```js [filename.js])
### Requirement 16: Navigation and Breadcrumbs
**User Story:** As a visitor, I want clear navigation between blog pages, so that I can easily move around the blog section.
#### Acceptance Criteria
1. THE Blog System SHALL display breadcrumb navigation using UBreadcrumb component on blog detail pages showing Home > Blog > [Post Title]
2. THE Blog System SHALL provide a "Back to Blog" link using localePath() to maintain locale context
3. THE Blog System SHALL use useRoute() to detect blog routes and highlight the blog section in TopNav component
4. WHERE previous/next posts exist chronologically, THE Blog System SHALL query adjacent posts using .sort() and .limit() and display navigation links
5. THE Blog System SHALL use localePath() helper from @nuxtjs/i18n for all blog navigation links to maintain locale context
6. THE Blog System SHALL implement keyboard navigation (arrow keys) for previous/next post navigation
+322
View File
@@ -0,0 +1,322 @@
# Implementation Plan
This implementation plan breaks down the blog system development into discrete, actionable coding tasks. Each task builds incrementally on previous work, with all code integrated and functional at each step.
## Task List
- [x] 1. Install and configure Nuxt Content module
- Install @nuxt/content package via pnpm
- Add @nuxt/content to modules array in nuxt.config.ts (before other modules)
- Configure content options: highlight themes (github-light/github-dark), markdown.mdc: true, toc depth
- Add content-specific route rules for caching (/blog, /fa/blog with swr: 3600)
- Verify installation by starting dev server and checking for content module initialization
- _Requirements: 1.1, 1.2, 1.5, 1.6_
- [x] 2. Create content directory structure and sample posts
- Create content/en/blog/ and content/fa/blog/ directories
- Create TypeScript interface for BlogPost extending ParsedContent in app/types/blog.ts
- Write 2 sample English blog posts with complete frontmatter (title, description, date, tags, image)
- Write 2 sample Persian blog posts with RTL content
- Include code blocks, headings, lists, and images in sample posts for testing
- Create one draft post to test draft filtering
- _Requirements: 2.1, 2.3, 2.4, 3.1, 3.2, 3.5, 3.6_
- [x] 3. Implement useBlog composable with utility functions
- Create app/composables/useBlog.ts file
- Implement calculateReadingTime function (200 words/min from body.children)
- Implement formatDate function using Intl.DateTimeFormat with locale support
- Implement extractUniqueTags function to aggregate tags from posts array
- Implement getBlogPath function returning locale-aware path
- Implement filterPostsBySearch function for title/description/tags filtering
- Implement filterPostsByTag function
- Export all functions with proper TypeScript types
- _Requirements: 4.7, 11.6_
- [x] 4. Update i18n translation files with blog keys
- Add blog section to i18n/locales/en.json with all required keys (title, explore, empty, readMore, readingTime, publishedOn, backToBlog, previousPost, nextPost, tableOfContents, searchPlaceholder, filterByTag, allPosts, noResults, copyCode, codeCopied, subscribe)
- Add corresponding Persian translations to i18n/locales/fa.json
- Verify translations are loaded by checking in dev tools
- _Requirements: 4.5, 8.1_
- [x] 5. Implement blog listing page
- [x] 5.1 Update app/pages/blog/index.vue with content fetching
- Replace placeholder content with queryContent implementation
- Use useAsyncData to fetch posts for current locale with .where({ draft: { $ne: true } })
- Apply .sort({ date: -1 }) and .only() for required fields
- Implement computed property for extracting unique tags using useBlog composable
- Add reactive refs for searchQuery and selectedTag
- Implement computed filteredPosts using filterPostsBySearch and filterPostsByTag
- _Requirements: 4.1, 4.2, 4.6, 4.8, 7.1_
- [x] 5.2 Create BlogCard component
- Create app/components/blog/BlogCard.vue
- Accept post prop with BlogPost type
- Use UCard as base component with hover effects
- Display NuxtImg for cover image with lazy loading and fallback
- Display title, description, formatted date, reading time, and tags
- Use UBadge for tags display
- Use localePath for navigation link
- Apply responsive styling
- _Requirements: 4.3, 10.1, 10.2_
- [x] 5.3 Create BlogSearch component
- Create app/components/blog/BlogSearch.vue
- Accept modelValue prop and emit update:modelValue
- Use UInput with search icon and clear button
- Implement debounce using useDebounceFn from VueUse (300ms)
- Add i18n placeholder text
- _Requirements: 13.2, 13.3_
- [x] 5.4 Create BlogTagFilter component
- Create app/components/blog/BlogTagFilter.vue
- Accept tags array and modelValue props
- Display tags as UButton or UBadge with click handlers
- Highlight active tag with primary color
- Add "All posts" option to clear filter
- Update URL query parameter using useRoute and navigateTo
- Read query parameter on mount to restore filter state
- Apply horizontal scroll on mobile
- _Requirements: 7.2, 7.3, 7.4, 7.5, 7.6_
- [x] 5.5 Create BlogEmpty component and integrate all components
- Create app/components/blog/BlogEmpty.vue with empty state message
- Import and use BlogSearch, BlogTagFilter, BlogCard, BlogEmpty in index.vue
- Implement grid layout for blog cards (responsive: 1 col mobile, 2 col tablet, 3 col desktop)
- Add page header with title and description using i18n
- Test search, filter, and empty state scenarios
- _Requirements: 4.4, 4.5, 13.4, 13.6_
- [x] 6. Implement blog detail page
- [x] 6.1 Update app/pages/blog/[...slug].vue with content fetching
- Replace placeholder content with queryContent().findOne() implementation
- Use useAsyncData with slug-based key
- Fetch post using _path matching for current locale
- Throw createError({ statusCode: 404 }) if post not found
- Fetch adjacent posts for prev/next navigation using separate query
- Calculate current post index in sorted posts array
- _Requirements: 5.1, 5.5, 16.4_
- [x] 6.2 Implement SEO meta tags and structured data
- Use useContentHead(post) for automatic meta generation
- Use useSeoMeta for custom title, og tags, twitter cards
- Set og:type to "article" with article:published_time and article:tag
- Use post.image or default cover image for og:image
- Add JSON-LD structured data using useHead with BlogPosting schema
- Include author, datePublished, headline in structured data
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.8_
- [x] 6.3 Create BlogPost metadata component
- Create app/components/blog/BlogPost.vue
- Accept post prop with BlogPost type
- Display post title as h1
- Display formatted date using formatDate from useBlog
- Display reading time using calculateReadingTime from useBlog
- Display author if available
- Display tags as UBadge components
- Display cover image using NuxtImg if available
- Use semantic HTML (article, header, time elements)
- _Requirements: 5.4_
- [x] 6.4 Implement ContentRenderer with Prose styling
- Use ContentRenderer component to render post.body
- Wrap in article element with proper semantic structure
- Apply dir attribute based on locale (rtl for fa, ltr for en)
- Add CSS to force LTR for code blocks in RTL context
- Verify Shiki syntax highlighting is working
- Test with sample posts containing various markdown elements
- _Requirements: 5.2, 5.3, 5.7, 9.1, 9.2, 9.3_
- [x] 6.5 Create BlogTableOfContents component
- Create app/components/blog/BlogTableOfContents.vue
- Accept toc prop from post.body.toc
- Render nested heading structure from toc.links
- Implement smooth scroll to heading on link click
- Use IntersectionObserver to track active section
- Highlight active section in TOC
- Make sticky on desktop (position: sticky)
- Make collapsible using UAccordion on mobile
- Only show if post has 3+ headings
- _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7_
- [x] 6.6 Create BlogNavigation component
- Create app/components/blog/BlogNavigation.vue
- Accept prev and next props (BlogPost | null)
- Display previous post link with title and arrow icon
- Display next post link with title and arrow icon
- Use UButton with icon slots
- Use localePath for navigation links
- Implement keyboard navigation (@keydown for arrow keys)
- Apply flexbox layout with space-between
- _Requirements: 16.4, 16.5, 16.6_
- [x] 6.7 Create breadcrumb navigation and integrate all components
- Use UBreadcrumb component with links array (Home > Blog > Post Title)
- Use localePath for breadcrumb links
- Import and use BlogPost, ContentRenderer, BlogTableOfContents, BlogNavigation
- Add "Back to Blog" link using localePath
- Implement responsive layout (TOC sidebar on desktop, inline on mobile)
- Test with both English and Persian posts
- Test prev/next navigation
- _Requirements: 16.1, 16.2, 16.3, 16.5_
- [x] 7. Implement custom Prose components
- [x] 7.1 Create ProseCode component with copy functionality
- Create app/components/content/ProseCode.vue
- Accept code, language, filename, highlights props
- Display language label if provided
- Display filename if provided
- Add copy button with icon
- Implement copy to clipboard using navigator.clipboard API
- Show success feedback (icon change or toast)
- Apply syntax highlighting theme based on color mode
- Support line highlighting from highlights prop
- _Requirements: 15.1, 15.2, 15.3, 15.4, 15.5, 15.6_
- [x] 7.2 Create custom MDC components
- Create app/components/content/BlogCallout.vue for callout boxes
- Accept title and type props (info, warning, success, error)
- Use UCard with colored border based on type
- Create app/components/content/Alert.vue for inline alerts
- Test MDC syntax in sample blog posts (::blog-callout, ::alert)
- _Requirements: 5.6_
- [x] 7.3 Customize Prose component styles
- Update app.config.ts with prose customization
- Define styles for ProseH1, ProseH2, ProseH3, ProseH4 (font sizes, spacing, colors)
- Define styles for ProseP (line height, spacing, colors)
- Define styles for ProseCode inline code (background, padding, border-radius)
- Define styles for ProseA links (color, hover effects)
- Define styles for ProseImg (responsive, rounded corners)
- Define styles for lists, blockquotes
- Test with sample posts to verify styling
- _Requirements: 5.7, 8.2_
- [x] 8. Implement RSS feed generation
- Create server/routes/blog/rss.xml.ts server route
- Create server/routes/fa/blog/rss.xml.ts for Persian locale
- Use serverQueryContent to fetch published posts for current locale
- Generate RSS 2.0 XML with channel metadata
- Include item elements for each post (title, link, guid, pubDate, description)
- Implement escapeXml helper function for XML safety
- Set Content-Type header to application/rss+xml
- _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7_
- [x] 9. Configure prerendering and route rules
- Update nitro.prerender.routes in nuxt.config.ts to include /blog and /fa/blog
- Add hooks to dynamically generate routes for all blog posts
- Verify route rules for caching are applied (/blog/**, /fa/blog/** with swr: 3600)
- _Requirements: 10.5_
- [x] 10. Add blog link to navigation
- Update app/components/common/TopNav.vue to include blog link
- Use localePath('/blog') for navigation
- Highlight blog link when on blog routes using useRoute()
- Add blog icon (i-twemoji-memo)
- Test navigation in both locales
- _Requirements: 16.3_
- [x] 11. Create default blog cover image
- Create public/img/blog/ directory for blog images
- Add .gitkeep file with instructions
- Use /img/blog/default-cover.jpg as fallback in posts
- _Requirements: 6.4_
- [ ]* 12. Performance optimization and testing
- Run Lighthouse audit on blog listing and detail pages
- Verify lazy loading of images below fold
- Check code splitting in network tab (separate chunks for blog components)
- Verify static generation of all routes
- Test hot-reload in development mode
- Measure and optimize Time to First Byte (TTFB)
- Verify Core Web Vitals (LCP, FID, CLS)
- _Requirements: 10.3, 10.4, 10.6, 10.7, 11.1_
- [ ]* 13. Accessibility testing and improvements
- Test keyboard navigation (Tab, Enter, arrow keys)
- Test with screen reader (NVDA or VoiceOver)
- Verify color contrast ratios using browser dev tools
- Ensure all interactive elements have focus indicators
- Add ARIA labels where needed
- Verify semantic HTML structure
- Test with reduced motion preference
- _Requirements: 8.3, 8.4, 8.5, 8.6_
- [ ]* 14. Cross-browser and responsive testing
- Test on Chrome, Firefox, Safari, Edge
- Test on mobile devices (iOS Safari, Chrome Android)
- Test on tablet viewports
- Verify RTL layout on Persian pages
- Test search and filter functionality on all devices
- Verify image optimization and lazy loading
- Test code block copy functionality
- _Requirements: 8.1, 9.1, 9.2, 9.4_
- [x] 15. Documentation and sample content
- Create README.md in content/ directory with authoring guidelines
- Document frontmatter schema and required fields
- Provide markdown examples for common elements
- Document MDC component usage (BlogCallout, Alert)
- Include best practices and publishing workflow
- _Requirements: 11.3, 11.4_
+410
View File
@@ -0,0 +1,410 @@
# Design Document: Page Transitions & UX Enhancement
## Overview
This design implements smooth page transitions, loading states, and component animations using Nuxt 4 best practices. The solution leverages Nuxt's built-in transition system, Vue's Transition component, and modern CSS animations to create a polished user experience while maintaining performance and accessibility.
## Architecture
### Transition Layers
The implementation consists of three distinct layers:
1. **Page-Level Transitions**: Global transitions applied to all route changes via `app.vue`
2. **Layout Transitions**: Smooth transitions when switching between layouts
3. **Component-Level Animations**: Micro-interactions for individual components
### Technology Stack
- **Nuxt 4 Page Transitions**: Built-in `<NuxtPage>` transition prop with enhanced performance
- **Vue 3 Transition Component**: For component-level animations
- **CSS Transforms & Opacity**: Hardware-accelerated animations
- **View Transitions API**: Native browser API for smooth page transitions (Chrome 111+)
- **NuxtLoadingIndicator**: Already implemented, will be enhanced
- **Tailwind CSS 4**: Utility classes for transition effects
## Components and Interfaces
### 1. Global Page Transitions
**Location**: `app/app.vue`
**Implementation Strategy**:
- Add `pageTransition` prop to `<NuxtPage>` component
- Define CSS transition classes in global styles
- Use fade + slight vertical movement for elegance
- Duration: 250-300ms for optimal perceived performance
**Transition Modes**:
- `out-in`: Wait for old page to leave before entering new page (prevents overlap)
- Prevents layout shift during navigation
### 2. Layout Transitions
**Location**: `app/layouts/default.vue`
**Implementation Strategy**:
- Add `layoutTransition` configuration in `nuxt.config.ts`
- Apply crossfade effect for layout changes
- Maintain scroll position where appropriate
### 3. Loading States Enhancement
**Current State**: `NuxtLoadingIndicator` already exists in `app.vue`
**Enhancements**:
- Add custom loading spinner for long operations
- Implement skeleton screens for blog post loading
- Add loading state to blog card components during navigation
### 4. Component Animations
**Target Components**:
a) **BlogCard** (`app/components/blog/BlogCard.vue`)
- Hover state: Subtle lift effect with shadow
- Entry animation: Staggered fade-in when list renders
b) **BlogNavigation** (`app/components/blog/BlogNavigation.vue`)
- Smooth hover states on prev/next buttons
- Icon animations on hover
c) **LanguageSwitcher** (`app/components/LanguageSwitcher.vue`)
- Dropdown animation with scale + fade
- Smooth active state transitions
d) **TopNav** (`app/components/common/TopNav.vue`)
- Smooth scroll-based appearance/disappearance
- Mobile menu slide-in animation
### 5. View Transitions API Integration (Native Browser API)
**Progressive Enhancement**:
- Use native View Transitions API for supported browsers (Chrome 111+, Edge 111+)
- Provides smooth, native transitions between pages
- Automatic fallback to CSS transitions for unsupported browsers
**Implementation via Nuxt 4**:
Nuxt 4 has built-in support for View Transitions API through the `experimental.viewTransition` flag:
```typescript
// nuxt.config.ts
export default defineNuxtConfig({
experimental: {
viewTransition: true
}
})
```
This enables automatic View Transitions for:
- Page navigation
- Route changes
- Dynamic content updates
**Manual Control** (when needed):
```typescript
// Composable: useViewTransition
const router = useRouter()
const navigateWithTransition = async (to: string) => {
if (document.startViewTransition) {
await document.startViewTransition(async () => {
await router.push(to)
}).finished
} else {
await router.push(to)
}
}
```
## Data Models
### Transition Configuration
```typescript
// types/transitions.ts
export interface TransitionConfig {
name: string
mode: 'in-out' | 'out-in' | 'default'
duration: number
appear?: boolean
}
export interface AnimationPreferences {
reducedMotion: boolean
enableViewTransitions: boolean
}
```
### CSS Custom Properties
```css
:root {
--transition-duration-fast: 150ms;
--transition-duration-base: 250ms;
--transition-duration-slow: 350ms;
--transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
--transition-timing-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
```
## Implementation Details
### 1. Page Transition Classes
**CSS Structure**:
```css
/* Enter transitions */
.page-enter-active,
.page-leave-active {
transition: all var(--transition-duration-base) var(--transition-timing);
}
.page-enter-from {
opacity: 0;
transform: translateY(10px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-10px);
}
```
### 2. Reduced Motion Support
**Media Query**:
```css
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
### 3. Staggered List Animations
**For Blog Cards**:
```css
.blog-card {
animation: fadeInUp var(--transition-duration-base) var(--transition-timing) backwards;
}
.blog-card:nth-child(1) { animation-delay: 0ms; }
.blog-card:nth-child(2) { animation-delay: 50ms; }
.blog-card:nth-child(3) { animation-delay: 100ms; }
/* ... */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
```
### 4. Language Switch Transition
**Special Handling**:
- Preserve scroll position using `scrollBehavior` in router
- Apply crossfade to prevent jarring content swap
- Maintain route structure during language change
```typescript
// Composable enhancement
const switchLanguageWithTransition = async (newLocale: string) => {
const scrollY = window.scrollY
await switchLocalePath(newLocale)
// Restore scroll after transition
nextTick(() => {
window.scrollTo(0, scrollY)
})
}
```
## Error Handling
### Transition Failures
1. **CSS Not Loaded**: Fallback to instant transitions
2. **JavaScript Errors**: Graceful degradation to no transitions
3. **Performance Issues**: Detect slow devices and reduce animation complexity
### Browser Compatibility
- **Modern Browsers**: Full transition support with View Transition API
- **Older Browsers**: CSS-only transitions
- **No JavaScript**: Basic CSS transitions still work
## Testing Strategy
### Visual Testing
1. **Manual Testing**:
- Navigate between all major routes
- Test language switching
- Verify mobile menu animations
- Check hover states on all interactive elements
2. **Browser Testing**:
- Chrome/Edge (View Transition API support)
- Firefox (CSS transitions only)
- Safari (CSS transitions only)
- Mobile browsers (iOS Safari, Chrome Mobile)
### Performance Testing
1. **Metrics to Monitor**:
- First Contentful Paint (FCP)
- Largest Contentful Paint (LCP)
- Cumulative Layout Shift (CLS)
- Time to Interactive (TTI)
2. **Animation Performance**:
- Use Chrome DevTools Performance tab
- Ensure 60fps during transitions
- Monitor paint and composite operations
### Accessibility Testing
1. **Reduced Motion**:
- Test with `prefers-reduced-motion: reduce`
- Verify animations are disabled or minimal
2. **Keyboard Navigation**:
- Ensure focus states are visible during transitions
- Test tab order during animations
3. **Screen Readers**:
- Verify ARIA live regions announce page changes
- Test with NVDA/JAWS/VoiceOver
## Configuration Changes
### nuxt.config.ts
```typescript
export default defineNuxtConfig({
// Enable View Transitions API (Nuxt 4 feature)
experimental: {
viewTransition: true
},
app: {
pageTransition: {
name: 'page',
mode: 'out-in'
},
layoutTransition: {
name: 'layout',
mode: 'out-in'
}
},
// Existing config...
})
```
**Note**: The `experimental.viewTransition` flag in Nuxt 4 automatically:
- Adds `<meta name="view-transition" content="same-origin">` to the head
- Enables View Transitions API for navigation
- Provides fallback for unsupported browsers
### CSS Organization
**New File**: `app/assets/css/transitions.css`
- Contains all transition and animation definitions
- Imported in `app/assets/css/main.css`
## Performance Considerations
### Optimization Strategies
1. **Use CSS Transforms**: Hardware-accelerated (GPU)
2. **Avoid Layout Thrashing**: Only animate `transform` and `opacity`
3. **Will-Change Property**: Apply sparingly to animated elements
4. **Reduce Animation Complexity**: Simpler animations on mobile devices
### Bundle Size Impact
- **CSS**: ~2-3KB additional (minified + gzipped)
- **JavaScript**: ~1KB for View Transition API detection
- **Total Impact**: Minimal (<5KB)
## Migration Path
### Phase 1: Core Page Transitions
- Implement global page transitions
- Add transition CSS classes
- Test across routes
### Phase 2: Component Animations
- Add hover states to interactive elements
- Implement staggered list animations
- Enhance loading states
### Phase 3: Advanced Features
- Integrate View Transition API
- Add custom transitions for specific routes
- Optimize performance
## Design Decisions & Rationale
### Why `out-in` Mode?
- Prevents content overlap during transitions
- Cleaner visual experience
- Slightly slower but more polished
### Why 250-300ms Duration?
- Research shows this is the sweet spot for perceived performance
- Fast enough to feel responsive
- Slow enough to be noticeable and polished
### Why CSS Over JavaScript?
- Better performance (GPU acceleration)
- Simpler to maintain
- Works without JavaScript
- Respects `prefers-reduced-motion` automatically
### Why View Transition API?
- Native browser support for smooth transitions
- Better performance than CSS alone
- Progressive enhancement approach
- Future-proof solution
## Nuxt 4 Specific Features
### Built-in View Transitions Support
Nuxt 4 provides first-class support for the View Transitions API:
1. **Automatic Setup**: Just enable `experimental.viewTransition`
2. **SSR Compatible**: Works with server-side rendering
3. **Progressive Enhancement**: Automatic fallback for older browsers
4. **Zero Configuration**: No additional setup needed for basic transitions
### Performance Improvements in Nuxt 4
- **Faster Hydration**: Improved client-side hydration performance
- **Better Code Splitting**: Automatic optimization for route-based code splitting
- **Enhanced Prefetching**: Smarter link prefetching for faster navigation
## References
- [Nuxt 4 Documentation](https://nuxt.com/docs)
- [Nuxt 4 View Transitions](https://nuxt.com/docs/getting-started/transitions#view-transitions-api-experimental)
- [Vue 3 Transition Component](https://vuejs.org/guide/built-ins/transition.html)
- [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API)
- [Chrome View Transitions Guide](https://developer.chrome.com/docs/web-platform/view-transitions/)
- [Web Animations Performance](https://web.dev/articles/animations-guide)
- [Reduced Motion Media Query](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
@@ -0,0 +1,72 @@
# Requirements Document
## Introduction
This feature aims to enhance the user experience of the Nuxt application by implementing smooth page transitions, component animations, and loading states following Nuxt best practices. The current implementation lacks visual feedback during navigation and state changes, resulting in an abrupt and less engaging user experience.
## Glossary
- **Application**: The Nuxt-based web application
- **Page Transition**: Visual animation that occurs when navigating between routes
- **Layout Transition**: Visual animation when switching between different layouts
- **Loading State**: Visual feedback shown during asynchronous operations
- **View Transition API**: Browser native API for smooth transitions between DOM states
- **Nuxt Transition**: Built-in Nuxt feature for handling page and layout transitions
## Requirements
### Requirement 1
**User Story:** As a user, I want to see smooth transitions when navigating between pages, so that the experience feels polished and professional
#### Acceptance Criteria
1. WHEN a user navigates to a different route, THE Application SHALL display a fade transition with appropriate timing
2. WHEN a page transition occurs, THE Application SHALL prevent layout shift during the animation
3. WHEN navigating between blog posts, THE Application SHALL apply consistent transition effects
4. THE Application SHALL complete page transitions within 300 milliseconds to maintain responsiveness
### Requirement 2
**User Story:** As a user, I want to see visual feedback during content loading, so that I know the application is responding to my actions
#### Acceptance Criteria
1. WHEN content is being fetched asynchronously, THE Application SHALL display a loading indicator
2. WHEN navigation occurs, THE Application SHALL show a progress bar at the top of the viewport
3. IF a page load exceeds 500 milliseconds, THEN THE Application SHALL display the loading indicator
4. WHEN loading completes, THE Application SHALL smoothly fade out the loading indicator
### Requirement 3
**User Story:** As a user, I want smooth animations when components appear or disappear, so that the interface feels responsive and intentional
#### Acceptance Criteria
1. WHEN a modal or overlay opens, THE Application SHALL animate its entrance with fade and scale effects
2. WHEN list items are rendered, THE Application SHALL stagger their appearance for visual interest
3. WHEN interactive elements receive focus or hover, THE Application SHALL provide smooth visual feedback
4. THE Application SHALL use CSS transforms for animations to ensure hardware acceleration
### Requirement 4
**User Story:** As a user, I want the language switcher to transition smoothly, so that changing languages feels seamless
#### Acceptance Criteria
1. WHEN the user switches language, THE Application SHALL maintain scroll position during the transition
2. WHEN language changes, THE Application SHALL apply a crossfade transition to content
3. THE Application SHALL preserve the current route path when switching languages
4. WHEN language transition occurs, THE Application SHALL complete within 400 milliseconds
### Requirement 5
**User Story:** As a developer, I want to use Nuxt best practices for transitions, so that the implementation is maintainable and performant
#### Acceptance Criteria
1. THE Application SHALL use Nuxt's built-in transition system for page transitions
2. THE Application SHALL leverage Vue's Transition component for component-level animations
3. THE Application SHALL use CSS-based animations rather than JavaScript animations where possible
4. THE Application SHALL implement transitions that respect user's reduced motion preferences
5. WHERE the browser supports View Transition API, THE Application SHALL utilize it for enhanced transitions
+97
View File
@@ -0,0 +1,97 @@
# Implementation Plan
- [x] 1. Enable Nuxt 4 View Transitions and configure global page transitions
- Enable `experimental.viewTransition` flag in `nuxt.config.ts`
- Configure `pageTransition` and `layoutTransition` settings
- Add View Transitions API polyfill detection
- _Requirements: 1.1, 1.2, 1.3, 1.4, 5.5_
- [ ] 2. Create global transition CSS styles
- Create `app/assets/css/transitions.css` file
- Define CSS custom properties for transition timing and durations
- Implement page transition classes (`.page-enter-active`, `.page-leave-active`, etc.)
- Add layout transition classes
- Implement `prefers-reduced-motion` media query support
- Import transitions.css in `app/assets/css/main.css`
- _Requirements: 1.1, 1.2, 1.4, 5.3, 5.4_
- [x] 3. Enhance NuxtLoadingIndicator and add loading states
- Review current `NuxtLoadingIndicator` configuration in `app.vue`
- Add custom loading spinner component for long operations
- Create skeleton loader component for blog posts
- Add loading state transitions with fade effects
- _Requirements: 2.1, 2.2, 2.3, 2.4_
- [x] 4. Add component-level animations to BlogCard
- Add hover state with lift effect and shadow to `app/components/blog/BlogCard.vue`
- Implement staggered fade-in animation for blog card list
- Use CSS transforms for hardware acceleration
- Add transition classes using Tailwind CSS 4 utilities
- _Requirements: 3.1, 3.2, 3.3, 3.4_
- [ ] 5. Enhance BlogNavigation with smooth animations
- Add smooth hover states to prev/next buttons in `app/components/blog/BlogNavigation.vue`
- Implement icon animations on hover
- Add transition effects for button states
- _Requirements: 3.1, 3.3_
- [ ] 6. Improve LanguageSwitcher transitions
- Add dropdown animation with scale and fade to `app/components/LanguageSwitcher.vue`
- Implement smooth active state transitions
- Preserve scroll position during language switch
- Add crossfade transition for content
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ] 7. Add TopNav scroll-based animations
- Implement smooth scroll-based appearance/disappearance in `app/components/common/TopNav.vue`
- Add mobile menu slide-in animation
- Use CSS transforms for smooth transitions
- _Requirements: 3.1, 3.3, 3.4_
- [ ] 8. Create View Transitions API composable
- Create `app/composables/useViewTransition.ts`
- Implement browser support detection
- Add manual transition control function
- Provide fallback for unsupported browsers
- _Requirements: 5.1, 5.2, 5.5_
- [ ] 9. Add View Transitions API custom animations
- Define custom view transition names for specific elements
- Add CSS for view transition animations
- Implement cross-fade effects for content areas
- Add slide animations for navigation elements
- _Requirements: 5.5_
- [ ]* 10. Test transitions across browsers and devices
- Test on Chrome/Edge (with View Transitions API)
- Test on Firefox and Safari (CSS fallback)
- Test on mobile browsers (iOS Safari, Chrome Mobile)
- Verify reduced motion preferences are respected
- Test keyboard navigation during transitions
- _Requirements: 5.4_
- [ ]* 11. Performance testing and optimization
- Measure FCP, LCP, CLS, and TTI metrics
- Use Chrome DevTools Performance tab to verify 60fps
- Monitor paint and composite operations
- Optimize animation complexity for mobile devices
- _Requirements: 3.4, 5.3_
+4 -1
View File
@@ -61,5 +61,8 @@
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"i18n", "i18n",
"i18n/locales" "i18n/locales"
] ],
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
} }
+25 -6
View File
@@ -95,6 +95,21 @@ i18n/ # Internationalization
## 🚀 Deployment ## 🚀 Deployment
### Static Site Generation (SSG)
This project is configured for full Static Site Generation with automatic blog post pre-rendering:
```bash
# Generate static files with pre-rendered blog posts
pnpm generate
```
The build process will:
- Pre-render all blog posts (English & Persian)
- Generate sitemap.xml automatically
- Create RSS feeds for both languages
- Output static files to `.output/public`
### Vercel (Recommended) ### Vercel (Recommended)
```bash ```bash
# Install Vercel CLI # Install Vercel CLI
@@ -106,16 +121,20 @@ vercel
Or connect your GitHub repository to Vercel for automatic deployments. Or connect your GitHub repository to Vercel for automatic deployments.
### Static Hosting ### Other Static Hosting Platforms
```bash
# Generate static files
pnpm generate
# Deploy .output/public to any static host Compatible with any static hosting service:
``` - **Netlify**: Deploy `.output/public` directory
- **Cloudflare Pages**: Connect GitHub repo or upload `.output/public`
- **GitHub Pages**: Deploy `.output/public` contents
- **AWS S3 + CloudFront**: Upload `.output/public` to S3 bucket
**Build Command**: `pnpm generate`
**Output Directory**: `.output/public`
### Environment Variables ### Environment Variables
- `NUXT_PUBLIC_LOAD_PLAUSIBLE` - Enable/disable Plausible analytics (optional) - `NUXT_PUBLIC_LOAD_PLAUSIBLE` - Enable/disable Plausible analytics (optional)
- `NUXT_PUBLIC_SITE_URL` - Site URL for sitemap and RSS (default: https://aliarghyani.vercel.app)
## 🧪 Testing the Structure ## 🧪 Testing the Structure
+149
View File
@@ -0,0 +1,149 @@
# راهنمای تست SSG (Static Site Generation)
## چطور بفهمیم SSG درست کار می‌کنه؟
### 1. بررسی فایل‌های تولید شده
اول بررسی کن که فایل‌های HTML واقعاً تولید شدن:
```bash
# بررسی ساختار فایل‌ها
ls .output/public/blog
ls .output/public/fa/blog
# باید این فایل‌ها رو ببینی:
# - index.html (صفحه لیست بلاگ)
# - getting-started-with-nuxt-content/index.html
# - nuxt-ui-components/index.html
# - typescript-best-practices/index.html
```
### 2. اجرای Preview Server
```bash
pnpm preview
```
این command یک static file server ساده راه‌اندازی می‌کنه که فقط فایل‌های HTML رو serve می‌کنه (بدون Node.js server).
### 3. تست‌های اصلی برای تأیید SSG
#### ✅ تست 1: بررسی HTML Source
1. مرورگر رو باز کن و برو به: `http://localhost:3000/blog`
2. کلیک راست کن و "View Page Source" یا `Ctrl+U` بزن
3. **چیزی که باید ببینی:**
- تمام محتوای HTML از قبل موجود هست (نه فقط `<div id="app"></div>`)
- تگ‌های `<meta>` برای SEO
- محتوای کامل پست‌های بلاگ در HTML
- **اگه فقط یک div خالی دیدی = SSG کار نکرده ❌**
- **اگه محتوای کامل دیدی = SSG موفق ✅**
#### ✅ تست 2: بررسی Network با اینترنت قطع
1. مرورگر رو باز کن
2. DevTools رو باز کن (`F12`)
3. به تب Network برو
4. صفحه رو Refresh کن
5. **چیزی که باید ببینی:**
- فقط یک request برای `index.html` (نه API call برای fetch کردن پست‌ها)
- فایل HTML حجم زیادی داره (چون محتوا توش هست)
- **اگه API call دیدی = SSR یا CSR هست، نه SSG ❌**
- **اگه فقط HTML دیدی = SSG موفق ✅**
#### ✅ تست 3: Disable JavaScript
1. DevTools رو باز کن (`F12`)
2. `Ctrl+Shift+P` بزن (Command Palette)
3. تایپ کن: "Disable JavaScript"
4. صفحه رو Refresh کن
5. **چیزی که باید ببینی:**
- محتوای بلاگ همچنان نمایش داده میشه
- فقط interactive features کار نمی‌کنن (مثل navigation)
- **اگه صفحه خالی شد = SSG نیست ❌**
- **اگه محتوا نمایش داده شد = SSG موفق ✅**
#### ✅ تست 4: بررسی سرعت بارگذاری
1. DevTools > Network > Throttling رو روی "Fast 3G" بذار
2. صفحه رو Refresh کن
3. **چیزی که باید ببینی:**
- محتوا خیلی سریع نمایش داده میشه (حتی با اینترنت کند)
- Time to First Contentful Paint (FCP) کمتر از 1 ثانیه
- **SSG = محتوا فوری نمایش داده میشه ✅**
- **SSR/CSR = باید منتظر بمونی تا محتوا load بشه ❌**
#### ✅ تست 5: بررسی Sitemap
```bash
# بررسی sitemap
curl http://localhost:3000/sitemap_index.xml
# یا در مرورگر:
# http://localhost:3000/sitemap_index.xml
```
باید لیست تمام URLهای بلاگ رو ببینی.
### 4. مقایسه SSG با SSR/CSR
| ویژگی | SSG (Static) | SSR (Server) | CSR (Client) |
|-------|-------------|--------------|--------------|
| HTML در source | ✅ کامل | ✅ کامل | ❌ خالی |
| نیاز به Node.js | ❌ نه | ✅ بله | ❌ نه |
| API Calls | ❌ نه | ✅ بله | ✅ بله |
| سرعت | ⚡ خیلی سریع | 🚀 سریع | 🐌 کند |
| SEO | ✅ عالی | ✅ عالی | ⚠️ ضعیف |
| هزینه Hosting | 💰 ارزان | 💰💰 گران | 💰 ارزان |
### 5. تست با curl (بدون مرورگر)
```bash
# دریافت HTML خام
curl http://localhost:3000/blog/getting-started-with-nuxt-content
# اگه محتوای کامل HTML رو دیدی = SSG موفق ✅
# اگه فقط یک div خالی دیدی = SSG کار نکرده ❌
```
### 6. بررسی فایل HTML مستقیماً
```bash
# باز کردن فایل HTML در مرورگر
start .output/public/blog/getting-started-with-nuxt-content/index.html
# یا در VSCode:
code .output/public/blog/getting-started-with-nuxt-content/index.html
```
باید تمام محتوای پست رو در HTML ببینی.
## 🎯 نتیجه‌گیری
**SSG موفق است اگر:**
- ✅ فایل‌های HTML با محتوای کامل تولید شدن
- ✅ View Source محتوای کامل رو نشون میده
- ✅ بدون JavaScript هم محتوا نمایش داده میشه
- ✅ هیچ API call برای fetch کردن محتوا نیست
- ✅ سرعت بارگذاری خیلی سریعه
- ✅ Sitemap تولید شده
**SSG کار نکرده اگر:**
- ❌ View Source فقط یک div خالی نشون میده
- ❌ API call برای fetch کردن پست‌ها وجود داره
- ❌ بدون JavaScript صفحه خالی میشه
- ❌ محتوا با تأخیر load میشه
## 🚀 Deploy
وقتی مطمئن شدی SSG درست کار می‌کنه، می‌تونی deploy کنی:
```bash
# فولدر .output/public رو به هر static hosting آپلود کن:
# - Vercel
# - Netlify
# - Cloudflare Pages
# - GitHub Pages
# - AWS S3 + CloudFront
```
هیچ Node.js server لازم نیست! فقط فایل‌های استاتیک.
+39
View File
@@ -25,6 +25,45 @@ export default defineAppConfig({
} }
} }
} }
},
// Prose component customization for blog content
prose: {
h1: {
class: 'text-4xl font-bold mb-6 mt-8 text-gray-900 dark:text-gray-100'
},
h2: {
class: 'text-3xl font-semibold mb-4 mt-8 text-gray-900 dark:text-gray-100'
},
h3: {
class: 'text-2xl font-semibold mb-3 mt-6 text-gray-900 dark:text-gray-100'
},
h4: {
class: 'text-xl font-semibold mb-2 mt-4 text-gray-900 dark:text-gray-100'
},
p: {
class: 'mb-4 leading-7 text-gray-700 dark:text-gray-300'
},
a: {
class: 'text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline underline-offset-2 transition-colors'
},
code: {
class: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-1.5 py-0.5 rounded text-sm font-mono'
},
img: {
class: 'rounded-lg my-6 w-full'
},
ul: {
class: 'mb-4 list-disc list-inside space-y-2 text-gray-700 dark:text-gray-300'
},
ol: {
class: 'mb-4 list-decimal list-inside space-y-2 text-gray-700 dark:text-gray-300'
},
li: {
class: 'leading-7'
},
blockquote: {
class: 'border-l-4 border-primary-500 pl-4 italic my-6 text-gray-700 dark:text-gray-300'
}
} }
} as any, } as any,
repoUrl: "https://github.com/aliarghyani/vue-cursor-rules", repoUrl: "https://github.com/aliarghyani/vue-cursor-rules",
+3 -9
View File
@@ -1,23 +1,17 @@
<template> <template>
<UApp :toaster="{ expand: false }"> <UApp :toaster="{ expand: false }">
<NuxtLoadingIndicator color="#6366F1" :height="3" :throttle="100" /> <NuxtLoadingIndicator color="#6366F1" :height="3" :throttle="100" :duration="2000" />
<TopNav /> <NuxtLayout>
<NuxtPage /> <NuxtPage />
<FooterCopyright /> </NuxtLayout>
<!-- <FloatingActions /> -->
</UApp> </UApp>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import FloatingActions from '@/components/portfolio/FloatingActions.vue'
import TopNav from '@/components/common/TopNav.vue'
import FooterCopyright from '@/components/common/FooterCopyright.vue'
import { usePortfolio } from '@/composables/usePortfolio' import { usePortfolio } from '@/composables/usePortfolio'
import { useLocaleSwitching } from '@/composables/useLocaleSwitching'
const { locale, locales } = useI18n() const { locale, locales } = useI18n()
const portfolio = usePortfolio() const portfolio = usePortfolio()
const { isLocaleSwitching } = useLocaleSwitching()
const activeLocale = computed(() => locales.value.find((item) => item.code === locale.value) ?? locales.value[0]) const activeLocale = computed(() => locales.value.find((item) => item.code === locale.value) ?? locales.value[0])
const langAttr = computed(() => (activeLocale.value as any)?.language ?? locale.value) const langAttr = computed(() => (activeLocale.value as any)?.language ?? locale.value)
+342
View File
@@ -0,0 +1,342 @@
/* Blog Content Styles - Optimized for Persian/RTL */
/* Base typography */
.blog-content {
font-size: 1.125rem; /* 18px */
line-height: 2; /* خط‌فاصله بیشتر */
letter-spacing: 0.01em;
color: rgb(55, 65, 81);
max-width: 75ch;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.dark .blog-content {
color: rgb(229, 231, 235);
}
/* RTL-specific improvements */
.blog-content-rtl {
line-height: 2.2 !important; /* فارسی نیاز به فاصله بیشتری داره */
letter-spacing: 0.02em !important;
}
/* Force LTR for code blocks in RTL */
.blog-content-rtl pre,
.blog-content-rtl code {
direction: ltr;
text-align: left;
}
/* Headings */
.blog-content h1,
.blog-content h2,
.blog-content h3,
.blog-content h4 {
scroll-margin-top: 2.5rem;
}
.blog-content h1 {
margin-top: 3rem;
margin-bottom: 2rem;
line-height: 1.4;
font-weight: 700;
font-size: 2.25em;
color: rgb(17, 24, 39);
}
.dark .blog-content h1 {
color: rgb(243, 244, 246);
}
.blog-content h2 {
margin-top: 3rem;
margin-bottom: 1.75rem;
line-height: 1.5;
font-weight: 700;
font-size: 1.875em;
color: rgb(17, 24, 39);
}
.dark .blog-content h2 {
color: rgb(243, 244, 246);
}
.blog-content h3 {
margin-top: 2.5rem;
margin-bottom: 1.5rem;
line-height: 1.6;
font-weight: 600;
font-size: 1.5em;
color: rgb(17, 24, 39);
}
.dark .blog-content h3 {
color: rgb(243, 244, 246);
}
.blog-content h4 {
margin-top: 2rem;
margin-bottom: 1.25rem;
line-height: 1.6;
font-weight: 600;
font-size: 1.25em;
color: rgb(17, 24, 39);
}
.dark .blog-content h4 {
color: rgb(243, 244, 246);
}
/* Paragraphs - فاصله زیاد برای خوانایی بهتر */
.blog-content p {
margin-top: 2rem;
margin-bottom: 2rem;
line-height: inherit;
}
/* First paragraph after heading */
.blog-content h1 + p,
.blog-content h2 + p,
.blog-content h3 + p,
.blog-content h4 + p {
margin-top: 1.25rem;
}
/* Lists */
.blog-content ul,
.blog-content ol {
margin-top: 2.25rem;
margin-bottom: 2.25rem;
padding-left: 2rem;
}
.blog-content-rtl ul,
.blog-content-rtl ol {
padding-left: 0;
padding-right: 2rem;
}
.blog-content li {
margin-top: 1rem;
margin-bottom: 1rem;
line-height: 2;
}
.blog-content-rtl li {
line-height: 2.2;
}
.blog-content li p {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
/* Nested lists */
.blog-content ul ul,
.blog-content ol ol,
.blog-content ul ol,
.blog-content ol ul {
margin-top: 1rem;
margin-bottom: 1rem;
}
/* Strong text */
.blog-content strong {
font-weight: 700;
color: rgb(17, 24, 39);
}
.dark .blog-content strong {
color: rgb(243, 244, 246);
}
/* Code blocks */
.blog-content pre {
margin-top: 3rem;
margin-bottom: 3rem;
line-height: 1.7;
font-size: 0.9375rem;
overflow-x: auto;
max-width: 100%;
}
@media (max-width: 1024px) {
.blog-content pre {
font-size: 0.875rem;
}
}
/* Inline code */
.blog-content code:not(pre code) {
padding: 0.25em 0.5em;
background-color: rgba(99, 102, 241, 0.1);
border-radius: 0.375rem;
font-size: 0.9em;
font-weight: 500;
color: rgb(79, 70, 229);
}
.dark .blog-content code:not(pre code) {
background-color: rgba(99, 102, 241, 0.2);
color: rgb(165, 180, 252);
}
/* Blockquotes */
.blog-content blockquote {
margin-top: 3rem;
margin-bottom: 3rem;
padding: 1.75rem;
border-left: 4px solid rgb(99, 102, 241);
background-color: rgba(99, 102, 241, 0.05);
border-radius: 0.75rem;
font-style: italic;
line-height: 2;
}
.blog-content-rtl blockquote {
border-left: none;
border-right: 4px solid rgb(99, 102, 241);
}
.dark .blog-content blockquote {
background-color: rgba(99, 102, 241, 0.1);
border-left-color: rgb(129, 140, 248);
}
.dark .blog-content-rtl blockquote {
border-right-color: rgb(129, 140, 248);
}
.blog-content blockquote p {
margin-top: 1rem;
margin-bottom: 1rem;
}
/* Links */
.blog-content a {
color: rgb(99, 102, 241);
text-decoration: underline;
text-decoration-color: rgba(99, 102, 241, 0.3);
text-underline-offset: 0.25em;
font-weight: 500;
transition: all 0.2s ease;
}
.blog-content a:hover {
color: rgb(79, 70, 229);
text-decoration-color: rgba(99, 102, 241, 0.8);
}
.dark .blog-content a {
color: rgb(129, 140, 248);
}
.dark .blog-content a:hover {
color: rgb(165, 180, 252);
}
/* Horizontal rule */
.blog-content hr {
margin-top: 3.5rem;
margin-bottom: 3.5rem;
border-color: rgba(148, 163, 184, 0.3);
}
.dark .blog-content hr {
border-color: rgba(71, 85, 105, 0.5);
}
/* Images */
.blog-content img {
margin-top: 3rem;
margin-bottom: 3rem;
border-radius: 0.75rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
/* Tables */
.blog-content table {
margin-top: 3rem;
margin-bottom: 3rem;
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
display: block;
overflow-x: auto;
max-width: 100%;
}
@media (max-width: 1024px) {
.blog-content table {
font-size: 0.875rem;
}
}
.blog-content th,
.blog-content td {
padding: 0.875rem 1rem;
border: 1px solid rgba(148, 163, 184, 0.3);
line-height: 1.8;
}
.blog-content th {
background-color: rgba(99, 102, 241, 0.1);
font-weight: 600;
color: rgb(17, 24, 39);
}
.dark .blog-content th {
background-color: rgba(99, 102, 241, 0.15);
color: rgb(243, 244, 246);
}
.dark .blog-content td {
border-color: rgba(71, 85, 105, 0.4);
}
/* Images - prevent overflow */
.blog-content img {
max-width: 100%;
height: auto;
}
/* Prevent horizontal scroll on all elements */
.blog-content * {
max-width: 100%;
}
.blog-content pre,
.blog-content code,
.blog-content table {
max-width: 100%;
}
/* Responsive */
@media (max-width: 640px) {
.blog-content {
font-size: 1rem;
}
.blog-content h1 {
font-size: 2em;
}
.blog-content h2 {
font-size: 1.625em;
}
.blog-content h3 {
font-size: 1.375em;
}
.blog-content pre {
font-size: 0.8125rem;
margin-left: -1rem;
margin-right: -1rem;
padding-left: 1rem;
padding-right: 1rem;
border-radius: 0;
}
}
+8 -2
View File
@@ -2,6 +2,9 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@nuxt/ui"; @import "@nuxt/ui";
@import "./transitions.css";
@import "./prose.css";
@import "./blog-content.css";
@source "../../components/**/*.{vue,js,ts}"; @source "../../components/**/*.{vue,js,ts}";
@source "../../layouts/**/*.vue"; @source "../../layouts/**/*.vue";
@@ -61,6 +64,7 @@
html { html {
overflow-y: scroll; overflow-y: scroll;
/* Avoid width variation */ /* Avoid width variation */
scroll-padding-top: 2rem; /* Offset for fixed navbar when using anchor links */
} }
html, html,
@@ -158,14 +162,16 @@
} }
/* Hide scrollbars for overflow containers */ /* Hide scrollbars for overflow containers */
.no-scrollbar { .no-scrollbar,
.scrollbar-hide {
-ms-overflow-style: none; -ms-overflow-style: none;
/* IE and Edge */ /* IE and Edge */
scrollbar-width: none; scrollbar-width: none;
/* Firefox */ /* Firefox */
} }
.no-scrollbar::-webkit-scrollbar { .no-scrollbar::-webkit-scrollbar,
.scrollbar-hide::-webkit-scrollbar {
display: none; display: none;
/* Chrome, Safari, Opera */ /* Chrome, Safari, Opera */
} }
+388
View File
@@ -0,0 +1,388 @@
/*
* Enhanced Prose Styles for Blog Content
* Optimized for both LTR and RTL (Persian) content
*/
/* Base prose container */
.prose {
color: rgb(55, 65, 81);
max-width: 65ch;
}
.dark .prose {
color: rgb(229, 231, 235);
}
/* Typography scale */
.prose {
font-size: 1.125rem; /* 18px */
line-height: 2;
}
/* RTL-specific adjustments */
.prose[dir="rtl"] {
line-height: 2.1;
letter-spacing: 0.02em;
}
/* Headings */
.prose :where(h1):not(:where([class~="not-prose"] *)) {
color: rgb(17, 24, 39);
font-weight: 800;
font-size: 2.25em;
margin-top: 0;
margin-bottom: 0.8888889em;
line-height: 1.1111111;
}
.dark .prose :where(h1):not(:where([class~="not-prose"] *)) {
color: rgb(243, 244, 246);
}
.prose :where(h2):not(:where([class~="not-prose"] *)) {
color: rgb(17, 24, 39);
font-weight: 700;
font-size: 1.875em;
margin-top: 2em;
margin-bottom: 1em;
line-height: 1.3333333;
}
.dark .prose :where(h2):not(:where([class~="not-prose"] *)) {
color: rgb(243, 244, 246);
}
.prose :where(h3):not(:where([class~="not-prose"] *)) {
color: rgb(17, 24, 39);
font-weight: 600;
font-size: 1.5em;
margin-top: 1.6em;
margin-bottom: 0.6em;
line-height: 1.6;
}
.dark .prose :where(h3):not(:where([class~="not-prose"] *)) {
color: rgb(243, 244, 246);
}
.prose :where(h4):not(:where([class~="not-prose"] *)) {
color: rgb(17, 24, 39);
font-weight: 600;
font-size: 1.25em;
margin-top: 1.5em;
margin-bottom: 0.5em;
line-height: 1.6;
}
.dark .prose :where(h4):not(:where([class~="not-prose"] *)) {
color: rgb(243, 244, 246);
}
/* Paragraphs */
.prose :where(p):not(:where([class~="not-prose"] *)) {
margin-top: 1.75em;
margin-bottom: 1.75em;
line-height: 2;
}
/* Lead paragraph */
.prose :where([class~="lead"]):not(:where([class~="not-prose"] *)) {
color: rgb(75, 85, 99);
font-size: 1.25em;
line-height: 1.8;
margin-top: 1.2em;
margin-bottom: 1.2em;
}
.dark .prose :where([class~="lead"]):not(:where([class~="not-prose"] *)) {
color: rgb(209, 213, 219);
}
/* Links */
.prose :where(a):not(:where([class~="not-prose"] *)) {
color: rgb(99, 102, 241);
text-decoration: underline;
text-decoration-color: rgba(99, 102, 241, 0.3);
text-underline-offset: 0.25em;
font-weight: 500;
transition: all 0.2s ease;
}
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
color: rgb(79, 70, 229);
text-decoration-color: rgba(99, 102, 241, 0.8);
}
.dark .prose :where(a):not(:where([class~="not-prose"] *)) {
color: rgb(129, 140, 248);
}
.dark .prose :where(a):not(:where([class~="not-prose"] *)):hover {
color: rgb(165, 180, 252);
}
/* Strong */
.prose :where(strong):not(:where([class~="not-prose"] *)) {
color: rgb(17, 24, 39);
font-weight: 700;
}
.dark .prose :where(strong):not(:where([class~="not-prose"] *)) {
color: rgb(243, 244, 246);
}
/* Lists */
.prose :where(ul):not(:where([class~="not-prose"] *)) {
list-style-type: disc;
margin-top: 2em;
margin-bottom: 2em;
padding-left: 1.625em;
}
.prose[dir="rtl"] :where(ul):not(:where([class~="not-prose"] *)) {
padding-left: 0;
padding-right: 1.625em;
}
.prose :where(ol):not(:where([class~="not-prose"] *)) {
list-style-type: decimal;
margin-top: 2em;
margin-bottom: 2em;
padding-left: 1.625em;
}
.prose[dir="rtl"] :where(ol):not(:where([class~="not-prose"] *)) {
padding-left: 0;
padding-right: 1.625em;
}
.prose :where(li):not(:where([class~="not-prose"] *)) {
margin-top: 0.75em;
margin-bottom: 0.75em;
line-height: 2;
}
.prose :where(li > p):not(:where([class~="not-prose"] *)) {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
/* Nested lists */
.prose :where(ul > li > *:first-child):not(:where([class~="not-prose"] *)) {
margin-top: 0.5em;
}
.prose :where(ul > li > *:last-child):not(:where([class~="not-prose"] *)) {
margin-bottom: 0.5em;
}
/* Blockquotes */
.prose :where(blockquote):not(:where([class~="not-prose"] *)) {
font-weight: 500;
font-style: italic;
color: rgb(17, 24, 39);
border-left-width: 0.25rem;
border-left-color: rgb(99, 102, 241);
quotes: "\201C""\201D""\2018""\2019";
margin-top: 2.5em;
margin-bottom: 2.5em;
padding-left: 1.5em;
padding-top: 1em;
padding-bottom: 1em;
background-color: rgba(99, 102, 241, 0.05);
border-radius: 0.5rem;
line-height: 2;
}
.prose[dir="rtl"] :where(blockquote):not(:where([class~="not-prose"] *)) {
border-left: none;
border-right-width: 0.25rem;
border-right-color: rgb(99, 102, 241);
padding-left: 1em;
padding-right: 1.5em;
}
.dark .prose :where(blockquote):not(:where([class~="not-prose"] *)) {
color: rgb(229, 231, 235);
border-left-color: rgb(129, 140, 248);
background-color: rgba(99, 102, 241, 0.1);
}
.dark .prose[dir="rtl"] :where(blockquote):not(:where([class~="not-prose"] *)) {
border-right-color: rgb(129, 140, 248);
}
.prose :where(blockquote p:first-of-type):not(:where([class~="not-prose"] *))::before {
content: open-quote;
}
.prose :where(blockquote p:last-of-type):not(:where([class~="not-prose"] *))::after {
content: close-quote;
}
/* Code */
.prose :where(code):not(:where([class~="not-prose"], pre *)) {
color: rgb(17, 24, 39);
font-weight: 600;
font-size: 0.875em;
background-color: rgba(99, 102, 241, 0.1);
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.dark .prose :where(code):not(:where([class~="not-prose"], pre *)) {
color: rgb(243, 244, 246);
background-color: rgba(99, 102, 241, 0.2);
}
.prose :where(code):not(:where([class~="not-prose"], pre *))::before,
.prose :where(code):not(:where([class~="not-prose"], pre *))::after {
content: "`";
}
/* Pre/Code blocks */
.prose :where(pre):not(:where([class~="not-prose"] *)) {
color: rgb(229, 231, 235);
background-color: rgb(31, 41, 55);
overflow-x: auto;
font-weight: 400;
font-size: 0.875em;
line-height: 1.7142857;
margin-top: 2.5em;
margin-bottom: 2.5em;
border-radius: 0.75rem;
padding: 1.5em;
}
.prose :where(pre code):not(:where([class~="not-prose"] *)) {
background-color: transparent;
border-width: 0;
border-radius: 0;
padding: 0;
font-weight: inherit;
color: inherit;
font-size: inherit;
font-family: inherit;
line-height: inherit;
}
.prose :where(pre code):not(:where([class~="not-prose"] *))::before,
.prose :where(pre code):not(:where([class~="not-prose"] *))::after {
content: none;
}
/* Horizontal rules */
.prose :where(hr):not(:where([class~="not-prose"] *)) {
border-color: rgb(229, 231, 235);
border-top-width: 1px;
margin-top: 3em;
margin-bottom: 3em;
}
.dark .prose :where(hr):not(:where([class~="not-prose"] *)) {
border-color: rgb(55, 65, 81);
}
/* Images */
.prose :where(img):not(:where([class~="not-prose"] *)) {
margin-top: 2.5em;
margin-bottom: 2.5em;
border-radius: 0.75rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.prose :where(figure):not(:where([class~="not-prose"] *)) {
margin-top: 2.5em;
margin-bottom: 2.5em;
}
.prose :where(figure > *):not(:where([class~="not-prose"] *)) {
margin-top: 0;
margin-bottom: 0;
}
.prose :where(figcaption):not(:where([class~="not-prose"] *)) {
color: rgb(107, 114, 128);
font-size: 0.875em;
line-height: 1.7;
margin-top: 1em;
text-align: center;
}
.dark .prose :where(figcaption):not(:where([class~="not-prose"] *)) {
color: rgb(156, 163, 175);
}
/* Tables */
.prose :where(table):not(:where([class~="not-prose"] *)) {
width: 100%;
table-layout: auto;
text-align: left;
margin-top: 2.5em;
margin-bottom: 2.5em;
font-size: 0.875em;
line-height: 1.7142857;
border-collapse: collapse;
}
.prose :where(thead):not(:where([class~="not-prose"] *)) {
border-bottom-width: 1px;
border-bottom-color: rgb(209, 213, 219);
}
.dark .prose :where(thead):not(:where([class~="not-prose"] *)) {
border-bottom-color: rgb(75, 85, 99);
}
.prose :where(thead th):not(:where([class~="not-prose"] *)) {
color: rgb(17, 24, 39);
font-weight: 600;
vertical-align: bottom;
padding-right: 0.75em;
padding-bottom: 0.75em;
padding-left: 0.75em;
background-color: rgba(99, 102, 241, 0.05);
}
.dark .prose :where(thead th):not(:where([class~="not-prose"] *)) {
color: rgb(243, 244, 246);
background-color: rgba(99, 102, 241, 0.1);
}
.prose :where(tbody tr):not(:where([class~="not-prose"] *)) {
border-bottom-width: 1px;
border-bottom-color: rgb(229, 231, 235);
}
.dark .prose :where(tbody tr):not(:where([class~="not-prose"] *)) {
border-bottom-color: rgb(55, 65, 81);
}
.prose :where(tbody tr:last-child):not(:where([class~="not-prose"] *)) {
border-bottom-width: 0;
}
.prose :where(tbody td):not(:where([class~="not-prose"] *)) {
vertical-align: baseline;
padding: 0.75em;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.prose {
font-size: 1rem;
}
.prose :where(h1):not(:where([class~="not-prose"] *)) {
font-size: 2em;
}
.prose :where(h2):not(:where([class~="not-prose"] *)) {
font-size: 1.625em;
}
.prose :where(h3):not(:where([class~="not-prose"] *)) {
font-size: 1.375em;
}
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Page & Layout Transitions
* Nuxt 4 transition styles for smooth page navigation
*/
/* CSS Custom Properties for Transitions */
:root {
--transition-duration-fast: 150ms;
--transition-duration-base: 250ms;
--transition-duration-slow: 350ms;
--transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
--transition-timing-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
/* Page Transitions */
.page-enter-active,
.page-leave-active {
transition: all var(--transition-duration-base) var(--transition-timing);
}
.page-enter-from {
opacity: 0;
transform: translateY(10px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-10px);
}
/* Layout Transitions */
.layout-enter-active,
.layout-leave-active {
transition: all var(--transition-duration-base) var(--transition-timing);
}
.layout-enter-from {
opacity: 0;
filter: blur(4px);
}
.layout-leave-to {
opacity: 0;
filter: blur(4px);
}
/* Staggered List Animations - Client-side only to avoid hydration mismatch */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Fade In Animation */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn var(--transition-duration-base) var(--transition-timing);
}
/* Scale Fade Animation (for modals, dropdowns) */
@keyframes scaleFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.scale-fade-in {
animation: scaleFadeIn var(--transition-duration-fast) var(--transition-timing);
}
/* Slide In Animations */
@keyframes slideInFromRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInFromLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.slide-in-right {
animation: slideInFromRight var(--transition-duration-base) var(--transition-timing);
}
.slide-in-left {
animation: slideInFromLeft var(--transition-duration-base) var(--transition-timing);
}
/* View Transitions API Custom Animations */
@supports (view-transition-name: main) {
/* Main content area */
.view-transition-main {
view-transition-name: main;
}
/* Slide transition for navigation */
::view-transition-old(main) {
animation: slideOutLeft 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-new(main) {
animation: slideInRight 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideOutLeft {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(-20px);
opacity: 0;
}
}
@keyframes slideInRight {
from {
transform: translateX(20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Crossfade for content areas */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 300ms;
}
}
/* Reduced Motion Support */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.page-enter-active,
.page-leave-active,
.layout-enter-active,
.layout-leave-active {
transition: none !important;
}
.page-enter-from,
.page-leave-to,
.layout-enter-from,
.layout-leave-to {
transform: none !important;
filter: none !important;
}
/* Disable View Transitions for reduced motion */
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
+52 -11
View File
@@ -3,23 +3,22 @@
<!-- Nuxt UI Select-based language picker --> <!-- Nuxt UI Select-based language picker -->
<USelect v-model="model" :items="items" value-key="value" size="sm" color="primary" variant="soft" <USelect v-model="model" :items="items" value-key="value" size="sm" color="primary" variant="soft"
:highlight="false" arrow :trailing="true" placeholder="Language" :highlight="false" arrow :trailing="true" placeholder="Language"
class="px-1 w-[64px] sm:w-[76px] rounded-full ring-1 ring-gray-200/70 dark:ring-gray-700/60 backdrop-blur-md shadow-sm h-[25px]" class="px-1 w-[64px] sm:w-[76px] rounded-full ring-1 ring-gray-200/70 dark:ring-gray-700/60 backdrop-blur-md shadow-sm h-[25px] hover:ring-primary-500/50 hover:shadow-md transition-all duration-200"
:ui="{ :ui="{
base: 'rounded-full', base: 'rounded-full',
value: 'sr-only',
trailingIcon: 'text-dimmed group-data-[state=open]:rotate-180 transition-transform duration-200', trailingIcon: 'text-dimmed group-data-[state=open]:rotate-180 transition-transform duration-200',
content: 'min-w-fit' content: 'min-w-fit scale-fade-in'
}" aria-label="Language selector"> }" :aria-label="t('nav.languageSelector')">
<!-- Leading icon in trigger (already provided by :icon via selectedIcon) --> <!-- Leading icon in trigger -->
<template #leading="{ ui }"> <template #leading>
<UIcon :name="selectedIcon" class="text-[16px]" /> <UIcon :name="selectedIcon" class="text-[16px]" />
</template> </template>
<template #item-leading="{ item }"> <template #item-leading="{ item }">
<UIcon :name="item.icon" class="text-[16px]" /> <UIcon :name="item.icon" class="text-[16px]" />
</template> </template>
<template #item-label="{ item }"> <template #item-label="{ item }">
<span>{{ item.label }}</span> <!-- <span>{{ item.label }}</span> -->
</template> </template>
</USelect> </USelect>
</ClientOnly> </ClientOnly>
@@ -29,7 +28,9 @@
import { ref, computed, watch } from '#imports' import { ref, computed, watch } from '#imports'
import { useLocaleSwitching, useLoadingIndicator } from '#imports' import { useLocaleSwitching, useLoadingIndicator } from '#imports'
const { locale, setLocale } = useI18n() const { locale, setLocale, t } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const router = useRouter()
type LangValue = 'en' | 'fa' type LangValue = 'en' | 'fa'
type Item = { label: string; value: LangValue; icon: string } type Item = { label: string; value: LangValue; icon: string }
@@ -53,14 +54,54 @@ const selectedIcon = computed<string>(() => items.value.find(i => i.value === mo
const { startLocaleSwitching } = useLocaleSwitching() const { startLocaleSwitching } = useLocaleSwitching()
const loading = useLoadingIndicator() const loading = useLoadingIndicator()
// On selection change, run visual feedback and update i18n // On selection change, navigate first then update locale
watch(model, (val, oldVal) => { watch(model, async (val, oldVal) => {
if (val === oldVal) return if (val === oldVal) return
// Preserve scroll position
const scrollY = window.scrollY
startLocaleSwitching(600) startLocaleSwitching(600)
if (loading) { if (loading) {
loading.start() loading.start()
}
// Get the current route path without locale prefix
const currentPath = router.currentRoute.value.path
const pathWithoutLocale = currentPath.replace(/^\/(en|fa)/, '')
// Check if we're on a blog post page
const isBlogPost = pathWithoutLocale.startsWith('/blog/') && pathWithoutLocale !== '/blog' && pathWithoutLocale !== '/blog/'
let newPath: string
if (isBlogPost) {
// If on a blog post, redirect to blog listing page in the new locale
newPath = val === 'en' ? '/blog' : `/${val}/blog`
} else {
// For other pages, try to navigate to the equivalent page
const newLocalePrefix = val === 'en' ? '' : `/${val}`
newPath = `${newLocalePrefix}${pathWithoutLocale || '/'}`
}
// Navigate to new path FIRST (before setLocale to avoid RTL/LTR flash)
if (newPath !== currentPath) {
await router.push(newPath)
}
// Update locale AFTER navigation
await setLocale(val)
// Restore scroll position after navigation (only if not redirecting from blog post)
await nextTick()
if (!isBlogPost) {
window.scrollTo(0, scrollY)
} else {
window.scrollTo(0, 0) // Scroll to top when redirecting to blog listing
}
if (loading) {
setTimeout(() => loading.finish(), 600) setTimeout(() => loading.finish(), 600)
} }
setLocale(val)
}) })
</script> </script>
+65
View File
@@ -0,0 +1,65 @@
<template>
<NuxtLink :to="localePath(getRoutePath((post as any).path))" class="block h-full">
<UCard class="h-full overflow-hidden hover:shadow-lg transition-shadow duration-300"
:ui="{ body: { base: 'p-0' } as any }">
<div
class="relative rounded-lg aspect-video w-full overflow-hidden bg-gradient-to-br from-primary-500 to-primary-700 dark:from-primary-600 dark:to-primary-900">
<img v-if="imageLoaded" :src="post.image" :alt="post.title" class="h-full w-full object-cover" loading="lazy"
@error="handleImageError" />
<div v-else class="flex h-full w-full items-center justify-center">
<div class="text-center text-white/90 p-6">
<UIcon name="i-heroicons-document-text" class="mx-auto h-12 w-12 mb-2 opacity-80" />
<p class="text-sm font-medium line-clamp-2">{{ post.title }}</p>
</div>
</div>
</div>
<div class="p-6">
<h2 class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
{{ post.title }}
</h2>
<p class="mb-4 line-clamp-2 text-gray-600 dark:text-gray-400">
{{ post.description }}
</p>
<div class="mb-4 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-500">
<time :datetime="post.date">{{ formatDate(post.date) }}</time>
<span>•</span>
<span>{{ t('blog.readingTime', { minutes: calculateReadingTime(post) }) }}</span>
</div>
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-2">
<UBadge v-for="tag in post.tags" :key="tag" variant="subtle" size="sm">
{{ tag }}
</UBadge>
</div>
</div>
</UCard>
</NuxtLink>
</template>
<script setup lang="ts">
import type { BlogPost } from '~/types/blog'
const props = defineProps<{
post: BlogPost
}>()
const { t, locale } = useI18n()
const localePath = useLocalePath()
const { formatDate, calculateReadingTime } = useBlog()
// Image loading state
const imageLoaded = ref(!!props.post.image)
const handleImageError = () => {
imageLoaded.value = false
}
// Convert collection path to route path (remove locale prefix)
const getRoutePath = (path: string) => {
// Remove any locale prefix from path: /en/blog/... or /fa/blog/... -> /blog/...
return path.replace(/^\/(en|fa)/, '')
}
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<div class="flex min-h-[400px] items-center justify-center">
<div class="text-center">
<div class="mb-4 text-6xl">📝</div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
{{ t('blog.noResults') }}
</h3>
<p class="text-gray-600 dark:text-gray-400">
{{ t('blog.empty') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
const { t } = useI18n()
</script>
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import type { BlogPost } from '~/types/blog'
const props = defineProps<{
prev: BlogPost | null
next: BlogPost | null
}>()
const { t, locale } = useI18n()
const localePath = useLocalePath()
const router = useRouter()
// Convert collection path to route path (remove locale prefix)
const getRoutePath = (path: string) => {
// Remove any locale prefix from path: /en/blog/... or /fa/blog/... -> /blog/...
return path.replace(/^\/(en|fa)/, '')
}
// Keyboard navigation
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'ArrowLeft' && props.prev) {
router.push(localePath(getRoutePath((props.prev as any).path)))
} else if (event.key === 'ArrowRight' && props.next) {
router.push(localePath(getRoutePath((props.next as any).path)))
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<nav class="flex justify-between items-center gap-4 mt-12 pt-8 border-t border-gray-200 dark:border-gray-800">
<!-- Previous Post -->
<div class="flex-1">
<NuxtLink v-if="prev" :to="localePath(getRoutePath((prev as any).path))" class="group block">
<UButton color="neutral" variant="ghost" size="lg"
class="w-full justify-start hover:scale-105 transition-transform duration-200">
<template #leading>
<UIcon name="i-heroicons-arrow-left"
class="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" />
</template>
<div class="text-left">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{{ t('blog.previousPost') }}
</div>
<div
class="text-sm font-medium text-gray-900 dark:text-gray-100 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors line-clamp-1">
{{ prev.title }}
</div>
</div>
</UButton>
</NuxtLink>
</div>
<!-- Next Post -->
<div class="flex-1">
<NuxtLink v-if="next" :to="localePath(getRoutePath((next as any).path))" class="group block">
<UButton color="neutral" variant="ghost" size="lg"
class="w-full justify-end hover:scale-105 transition-transform duration-200">
<div class="text-right">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{{ t('blog.nextPost') }}
</div>
<div
class="text-sm font-medium text-gray-900 dark:text-gray-100 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors line-clamp-1">
{{ next.title }}
</div>
</div>
<template #trailing>
<UIcon name="i-heroicons-arrow-right"
class="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" />
</template>
</UButton>
</NuxtLink>
</div>
</nav>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { BlogPost } from '~/types/blog'
const props = defineProps<{
post: BlogPost
}>()
const { formatDate, calculateReadingTime } = useBlog()
const readingTime = computed(() => calculateReadingTime(props.post))
// Image loading state
const imageLoaded = ref(!!props.post.image)
const handleImageError = () => {
imageLoaded.value = false
}
</script>
<template>
<article>
<header class="mb-8">
<!-- Cover Image -->
<div v-if="post.image"
class="relative w-full aspect-video rounded-lg overflow-hidden mb-6 bg-gradient-to-br from-primary-500 to-primary-700 dark:from-primary-600 dark:to-primary-900">
<img v-if="imageLoaded" :src="post.image" :alt="post.title" class="w-full h-full object-cover"
@error="handleImageError" />
<div v-else class="flex h-full w-full items-center justify-center">
<div class="text-center text-white/90 p-8">
<UIcon name="i-heroicons-photo" class="mx-auto h-16 w-16 mb-3 opacity-80" />
<p class="text-lg font-medium">{{ post.title }}</p>
</div>
</div>
</div>
<!-- Title -->
<h1 class="text-4xl md:text-5xl font-bold mb-4 text-gray-900 dark:text-gray-100">
{{ post.title }}
</h1>
<!-- Metadata -->
<div class="flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mb-4">
<!-- Date -->
<time :datetime="post.date" class="flex items-center gap-2">
<UIcon name="i-heroicons-calendar" class="w-5 h-5" />
{{ formatDate(post.date) }}
</time>
<!-- Reading Time -->
<span class="flex items-center gap-2">
<UIcon name="i-heroicons-clock" class="w-5 h-5" />
{{ $t('blog.readingTime', { minutes: readingTime }) }}
</span>
<!-- Author -->
<span v-if="post.author" class="flex items-center gap-2">
<UIcon name="i-heroicons-user" class="w-5 h-5" />
{{ post.author }}
</span>
</div>
<!-- Tags -->
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-2">
<UBadge v-for="tag in post.tags" :key="tag" color="primary" variant="soft" size="md">
{{ tag }}
</UBadge>
</div>
</header>
</article>
</template>
+35
View File
@@ -0,0 +1,35 @@
<template>
<UInput :model-value="modelValue" :placeholder="t('blog.searchPlaceholder')" icon="i-heroicons-magnifying-glass"
size="lg" :ui="{ icon: { trailing: { pointer: '' } } }" @update:model-value="handleInput">
<template v-if="modelValue" #trailing>
<UButton color="gray" variant="link" icon="i-heroicons-x-mark-20-solid" :padded="false" @click="clearSearch" />
</template>
</UInput>
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { t } = useI18n()
// Debounced input handler
const debouncedEmit = useDebounceFn((value: string) => {
emit('update:modelValue', value)
}, 300)
const handleInput = (value: string) => {
debouncedEmit(value)
}
const clearSearch = () => {
emit('update:modelValue', '')
}
</script>
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
interface TocLink {
id: string
text: string
depth: number
children?: TocLink[]
}
const props = defineProps<{
toc: {
links: TocLink[]
}
mobile?: boolean
}>()
const { t } = useI18n()
const activeId = ref<string>('')
// Check if TOC should be displayed (3+ headings)
const shouldShowToc = computed(() => {
const countLinks = (links: TocLink[]): number => {
return links.reduce((count, link) => {
return count + 1 + (link.children ? countLinks(link.children) : 0)
}, 0)
}
return countLinks(props.toc.links) >= 3
})
// Smooth scroll to heading with offset for sticky header
const scrollToHeading = (id: string) => {
const element = document.getElementById(id)
if (element) {
const offset = 100 // Offset for sticky header
const elementPosition = element.getBoundingClientRect().top + window.pageYOffset
const offsetPosition = elementPosition - offset
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
})
activeId.value = id
}
}
// Track active section with IntersectionObserver
onMounted(() => {
const headings = document.querySelectorAll('article h2, article h3, article h4')
const observer = new IntersectionObserver(
(entries) => {
// Find the first intersecting heading
const intersecting = entries.filter(entry => entry.isIntersecting)
if (intersecting.length > 0) {
// Sort by position and get the topmost one
const topmost = intersecting.sort((a, b) =>
a.boundingClientRect.top - b.boundingClientRect.top
)[0]
if (topmost && topmost.target.id) {
activeId.value = topmost.target.id
}
}
},
{
rootMargin: '-100px 0px -66% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1]
}
)
// Observe all headings
headings.forEach((heading) => observer.observe(heading))
// Cleanup
onUnmounted(() => {
headings.forEach((heading) => observer.unobserve(heading))
})
})
</script>
<template>
<div v-if="shouldShowToc">
<!-- Desktop TOC -->
<div v-if="!mobile"
class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4 max-h-[calc(100vh-7rem)] overflow-y-auto">
<h3 class="text-sm font-semibold mb-3 text-gray-900 dark:text-gray-100 uppercase tracking-wide">
{{ t('blog.tableOfContents') }}
</h3>
<nav>
<ul class="space-y-1 text-sm">
<template v-for="link in toc.links" :key="link.id">
<li>
<a :href="`#${link.id}`" :class="[
'block py-1.5 px-2 rounded transition-all',
activeId === link.id
? 'text-primary-600 dark:text-primary-400 font-medium bg-primary-50 dark:bg-primary-900/20'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800/50'
]" @click.prevent="scrollToHeading(link.id)">
{{ link.text }}
</a>
<!-- Nested children (h3) -->
<ul v-if="link.children && link.children.length > 0"
class="ml-3 mt-0.5 space-y-0.5 border-l-2 border-gray-200 dark:border-gray-700 pl-3">
<li v-for="child in link.children" :key="child.id">
<a :href="`#${child.id}`" :class="[
'block py-1 px-2 rounded text-xs transition-all',
activeId === child.id
? 'text-primary-600 dark:text-primary-400 font-medium bg-primary-50 dark:bg-primary-900/20'
: 'text-gray-500 dark:text-gray-500 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800/50'
]" @click.prevent="scrollToHeading(child.id)">
{{ child.text }}
</a>
</li>
</ul>
</li>
</template>
</ul>
</nav>
</div>
<!-- Mobile: Collapsible accordion -->
<UAccordion v-if="mobile" :items="[
{
label: t('blog.tableOfContents'),
icon: 'i-heroicons-list-bullet',
defaultOpen: false,
slot: 'toc'
}
]">
<template #toc>
<ul class="space-y-2 text-sm p-4">
<template v-for="link in toc.links" :key="link.id">
<li>
<a :href="`#${link.id}`" :class="[
'block py-1 transition-colors',
activeId === link.id
? 'text-primary-600 dark:text-primary-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
]" @click.prevent="scrollToHeading(link.id)">
{{ link.text }}
</a>
<!-- Nested children (h3) -->
<ul v-if="link.children && link.children.length > 0" class="ml-4 mt-1 space-y-1">
<li v-for="child in link.children" :key="child.id">
<a :href="`#${child.id}`" :class="[
'block py-1 transition-colors',
activeId === child.id
? 'text-primary-600 dark:text-primary-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
]" @click.prevent="scrollToHeading(child.id)">
{{ child.text }}
</a>
</li>
</ul>
</li>
</template>
</ul>
</template>
</UAccordion>
</div>
</template>
+59
View File
@@ -0,0 +1,59 @@
<template>
<div v-if="tags.length > 0" class="overflow-x-auto scrollbar-hide -mx-1 px-1">
<div class="flex flex-wrap gap-1.5 py-1">
<!-- All Posts Badge -->
<button :class="[
'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
'ring-1 ring-inset',
!modelValue
? 'bg-primary-500 text-white ring-primary-500 shadow-sm'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 ring-gray-200 dark:ring-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 hover:ring-gray-300 dark:hover:ring-gray-600 hover:shadow-sm hover:scale-105'
]" @click="selectTag(null)">
{{ t('blog.allPosts') }}
</button>
<!-- Tag Badges -->
<button v-for="tag in tags" :key="tag" :class="[
'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
'ring-1 ring-inset',
modelValue === tag
? 'bg-primary-500 text-white ring-primary-500 shadow-sm'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 ring-gray-200 dark:ring-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 hover:ring-gray-300 dark:hover:ring-gray-600 hover:shadow-sm hover:scale-105'
]" @click="selectTag(tag)">
{{ tag }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
tags: string[]
modelValue: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const { t } = useI18n()
const route = useRoute()
// Read query parameter on mount to restore filter state
onMounted(() => {
const tagFromQuery = route.query.tag as string | undefined
if (tagFromQuery && props.tags.includes(tagFromQuery)) {
emit('update:modelValue', tagFromQuery)
}
})
// Select tag and update URL query parameter
const selectTag = async (tag: string | null) => {
emit('update:modelValue', tag)
// Update URL query parameter
await navigateTo({
query: tag ? { tag } : {}
})
}
</script>
+6 -3
View File
@@ -2,8 +2,14 @@
<footer class="py-10"> <footer class="py-10">
<UContainer> <UContainer>
<div class="flex flex-col items-center gap-4 text-center text-sm text-gray-600 dark:text-gray-400"> <div class="flex flex-col items-center gap-4 text-center text-sm text-gray-600 dark:text-gray-400">
<ClientOnly>
<NuxtImg :src="logoSrc" alt="Ali Arghyani logo" width="64" height="64" class="h-12 w-12" format="png" <NuxtImg :src="logoSrc" alt="Ali Arghyani logo" width="64" height="64" class="h-12 w-12" format="png"
loading="lazy" /> loading="lazy" />
<template #fallback>
<NuxtImg src="/favicon/android-chrome-192x192.png" alt="Ali Arghyani logo" width="64" height="64"
class="h-12 w-12" format="png" loading="lazy" />
</template>
</ClientOnly>
<p>&copy; {{ currentYear }}, <span class="font-semibold text-gray-900 dark:text-gray-100">AliArghyani</span> - <p>&copy; {{ currentYear }}, <span class="font-semibold text-gray-900 dark:text-gray-100">AliArghyani</span> -
All rights reserved.</p> All rights reserved.</p>
<a href="https://github.com/aliarghyani" target="_blank" rel="noopener noreferrer" <a href="https://github.com/aliarghyani" target="_blank" rel="noopener noreferrer"
@@ -24,9 +30,6 @@ const colorMode = useColorMode()
const currentYear = computed(() => new Date().getFullYear()) const currentYear = computed(() => new Date().getFullYear())
const logoSrc = computed(() => { const logoSrc = computed(() => {
if (colorMode.unknown) {
return '/favicon/android-chrome-192x192.png'
}
return colorMode.value === 'dark' return colorMode.value === 'dark'
? '/favicon/android-chrome-192x192-dark.png' ? '/favicon/android-chrome-192x192-dark.png'
: '/favicon/android-chrome-192x192.png' : '/favicon/android-chrome-192x192.png'
+101 -26
View File
@@ -1,18 +1,18 @@
<template> <template>
<ClientOnly> <nav class="fixed inset-x-0 top-0 z-50 pointer-events-auto transition-transform duration-300" data-section-header>
<nav class="fixed inset-x-0 top-0 z-50 pointer-events-auto" data-section-header>
<div class="mx-auto max-w-6xl px-4 pt-2"> <div class="mx-auto max-w-6xl px-4 pt-2">
<div <div
class="backdrop-blur-md bg-white/80 dark:bg-slate-900/70 shadow-md rounded-2xl border border-white/30 dark:border-slate-700/50 pointer-events-auto"> class="backdrop-blur-md bg-white/80 dark:bg-slate-900/70 shadow-md rounded-2xl border border-white/30 dark:border-slate-700/50 pointer-events-auto transition-all duration-300">
<div class="flex items-center justify-between px-2 py-2"> <div class="flex items-center justify-between px-2 py-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- Home --> <!-- Home -->
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<UTooltip :text="t('nav.home')"> <UTooltip :text="t('nav.home')">
<UButton class="cursor-pointer" :class="[isActive('hero') ? activeClass : inactiveClass]" <UButton class="cursor-pointer transition-all duration-200"
variant="soft" square icon="i-twemoji-house" :aria-label="t('nav.home')" @click="goTo('hero')" /> :class="[isActive('hero') ? activeClass : inactiveClass]" variant="soft" square icon="i-twemoji-house"
:aria-label="t('nav.home')" @click="goTo('hero')" />
</UTooltip> </UTooltip>
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors" <button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
:class="[isActive('hero') ? labelActiveClass : labelInactiveClass]" @click="goTo('hero')"> :class="[isActive('hero') ? labelActiveClass : labelInactiveClass]" @click="goTo('hero')">
{{ t('nav.home') }} {{ t('nav.home') }}
</button> </button>
@@ -21,11 +21,11 @@
<!-- Skills --> <!-- Skills -->
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.skills')"> <UTooltip :text="t('sections.skills')">
<UButton class="cursor-pointer" :class="[isActive('skills') ? activeClass : inactiveClass]" <UButton class="cursor-pointer transition-all duration-200"
variant="soft" square icon="i-twemoji-hammer-and-wrench" :aria-label="t('sections.skills')" :class="[isActive('skills') ? activeClass : inactiveClass]" variant="soft" square
@click="goTo('skills')" /> icon="i-twemoji-hammer-and-wrench" :aria-label="t('sections.skills')" @click="goTo('skills')" />
</UTooltip> </UTooltip>
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors" <button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
:class="[isActive('skills') ? labelActiveClass : labelInactiveClass]" @click="goTo('skills')"> :class="[isActive('skills') ? labelActiveClass : labelInactiveClass]" @click="goTo('skills')">
{{ t('sections.skills') }} {{ t('sections.skills') }}
</button> </button>
@@ -34,11 +34,11 @@
<!-- Work --> <!-- Work -->
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.work')"> <UTooltip :text="t('sections.work')">
<UButton class="cursor-pointer" :class="[isActive('work') ? activeClass : inactiveClass]" <UButton class="cursor-pointer transition-all duration-200"
variant="soft" square icon="i-twemoji-briefcase" :aria-label="t('sections.work')" :class="[isActive('work') ? activeClass : inactiveClass]" variant="soft" square
@click="goTo('work')" /> icon="i-twemoji-briefcase" :aria-label="t('sections.work')" @click="goTo('work')" />
</UTooltip> </UTooltip>
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors" <button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
:class="[isActive('work') ? labelActiveClass : labelInactiveClass]" @click="goTo('work')"> :class="[isActive('work') ? labelActiveClass : labelInactiveClass]" @click="goTo('work')">
{{ t('sections.work') }} {{ t('sections.work') }}
</button> </button>
@@ -47,15 +47,32 @@
<!-- Projects --> <!-- Projects -->
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.projects')"> <UTooltip :text="t('sections.projects')">
<UButton class="cursor-pointer" :class="[isActive('projects') ? activeClass : inactiveClass]" <UButton class="cursor-pointer transition-all duration-200"
variant="soft" square icon="i-twemoji-rocket" :aria-label="t('sections.projects')" :class="[isActive('projects') ? activeClass : inactiveClass]" variant="soft" square
@click="goTo('projects')" /> icon="i-twemoji-rocket" :aria-label="t('sections.projects')" @click="goTo('projects')" />
</UTooltip> </UTooltip>
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors" <button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
:class="[isActive('projects') ? labelActiveClass : labelInactiveClass]" @click="goTo('projects')"> :class="[isActive('projects') ? labelActiveClass : labelInactiveClass]" @click="goTo('projects')">
{{ t('sections.projects') }} {{ t('sections.projects') }}
</button> </button>
</div> </div>
<!-- Blog -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.blog')">
<NuxtLink :to="localePath('/blog')">
<UButton class="cursor-pointer transition-all duration-200"
:class="[isBlogActive ? activeClass : inactiveClass]" variant="soft" square icon="i-twemoji-memo"
:aria-label="t('sections.blog')" />
</NuxtLink>
</UTooltip>
<NuxtLink :to="localePath('/blog')">
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
:class="[isBlogActive ? labelActiveClass : labelInactiveClass]">
{{ t('sections.blog') }}
</button>
</NuxtLink>
</div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -66,13 +83,11 @@
</div> </div>
</div> </div>
</nav> </nav>
</ClientOnly>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ThemeCustomizer from '@/components/common/ThemeCustomizer.vue' import ThemeCustomizer from '@/components/common/ThemeCustomizer.vue'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue' import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import { useSectionObserver, type SectionId } from '@/composables/useSectionObserver'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
@@ -88,24 +103,84 @@ const sectionIds = ['hero', 'skills', 'work', 'projects'] as const
type Target = typeof sectionIds[number] type Target = typeof sectionIds[number]
const isHome = computed(() => route.path === localePath('/')) const isHome = computed(() => route.path === localePath('/'))
const isBlogActive = computed(() => route.path.includes('/blog'))
const { activeSection, scrollToSection } = useSectionObserver({ // Active section tracking (client-side only)
ids: [...sectionIds] as SectionId[], const activeSection = ref<Target | null>(null)
headerSelector: 'nav[data-section-header]', const isMounted = ref(false)
offset: 80,
enabled: isHome onMounted(() => {
isMounted.value = true
// Only setup intersection observer on homepage
if (!isHome.value) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.id as Target
if (sectionIds.includes(id)) {
activeSection.value = id
}
}
})
},
{
rootMargin: '-80px 0px -80% 0px',
threshold: 0
}
)
// Observe all sections
sectionIds.forEach((id) => {
const element = document.getElementById(id)
if (element) {
observer.observe(element)
}
})
// Cleanup
onUnmounted(() => {
observer.disconnect()
})
}) })
const isActive = (id: Target) => activeSection.value === id const isActive = (id: Target) => {
// During SSR or before mount, no section is active
if (!isMounted.value) return false
return activeSection.value === id
}
function scrollToSection(id: Target) {
if (typeof window === 'undefined') return
const element = document.getElementById(id)
if (element) {
const headerOffset = 80
const elementPosition = element.getBoundingClientRect().top
const offsetPosition = elementPosition + window.pageYOffset - headerOffset
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
})
}
}
async function goTo(id: Target) { async function goTo(id: Target) {
const homePath = localePath('/') const homePath = localePath('/')
if (route.path !== homePath) { if (route.path !== homePath) {
await router.push(homePath) await router.push(homePath)
await nextTick() await nextTick()
// Wait for next frame to ensure DOM is ready
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(() => scrollToSection(id)) requestAnimationFrame(() => scrollToSection(id))
} else { } else {
scrollToSection(id) scrollToSection(id)
} }
} else {
scrollToSection(id)
}
} }
</script> </script>
+43
View File
@@ -0,0 +1,43 @@
<template>
<div :class="alertClass"
class="my-6 flex items-center gap-3 rounded-xl px-4 py-3.5 ring-1 ring-inset backdrop-blur-sm">
<UIcon :name="icon" class="h-5 w-5 flex-shrink-0" />
<div class="flex-1 text-sm leading-relaxed">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'success' | 'error'
}>(),
{
type: 'info'
}
)
const typeConfig = {
info: {
icon: 'i-heroicons-information-circle',
class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200'
},
warning: {
icon: 'i-heroicons-exclamation-triangle',
class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-200'
},
success: {
icon: 'i-heroicons-check-circle',
class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
},
error: {
icon: 'i-heroicons-x-circle',
class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200'
}
}
const config = computed(() => typeConfig[props.type])
const icon = computed(() => config.value.icon)
const alertClass = computed(() => config.value.class)
</script>
+63
View File
@@ -0,0 +1,63 @@
<template>
<UCard :ui="{
base: 'my-6',
body: { padding: 'p-4 sm:p-5' },
ring: 'ring-2',
divide: ''
}" :class="cardClass">
<div class="flex items-start gap-3">
<UIcon :name="icon" :class="iconClass" class="mt-0.5 h-5 w-5 flex-shrink-0" />
<div class="flex-1">
<h4 v-if="title" class="mb-2 font-semibold" :class="titleClass">{{ title }}</h4>
<div class="prose-sm dark:prose-invert">
<slot />
</div>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'success' | 'error'
title?: string
}>(),
{
type: 'info'
}
)
const typeConfig = {
info: {
icon: 'i-heroicons-information-circle',
cardClass: 'ring-blue-500/20 bg-blue-50 dark:bg-blue-950/20',
iconClass: 'text-blue-600 dark:text-blue-400',
titleClass: 'text-blue-900 dark:text-blue-100'
},
warning: {
icon: 'i-heroicons-exclamation-triangle',
cardClass: 'ring-yellow-500/20 bg-yellow-50 dark:bg-yellow-950/20',
iconClass: 'text-yellow-600 dark:text-yellow-400',
titleClass: 'text-yellow-900 dark:text-yellow-100'
},
success: {
icon: 'i-heroicons-check-circle',
cardClass: 'ring-green-500/20 bg-green-50 dark:bg-green-950/20',
iconClass: 'text-green-600 dark:text-green-400',
titleClass: 'text-green-900 dark:text-green-100'
},
error: {
icon: 'i-heroicons-x-circle',
cardClass: 'ring-red-500/20 bg-red-50 dark:bg-red-950/20',
iconClass: 'text-red-600 dark:text-red-400',
titleClass: 'text-red-900 dark:text-red-100'
}
}
const config = computed(() => typeConfig[props.type])
const icon = computed(() => config.value.icon)
const cardClass = computed(() => config.value.cardClass)
const iconClass = computed(() => config.value.iconClass)
const titleClass = computed(() => config.value.titleClass)
</script>
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
const props = defineProps<{
code?: string
language?: string
filename?: string
highlights?: number[]
class?: string
}>()
const { t } = useI18n()
const copied = ref(false)
const isHovered = ref(false)
// Copy code to clipboard
const copyCode = async () => {
if (props.code) {
try {
await navigator.clipboard.writeText(props.code)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
console.error('Failed to copy code:', err)
}
}
}
// Get language label
const languageLabel = computed(() => {
if (!props.language) return null
const languageMap: Record<string, string> = {
js: 'JavaScript',
ts: 'TypeScript',
vue: 'Vue',
html: 'HTML',
css: 'CSS',
scss: 'SCSS',
bash: 'Bash',
sh: 'Shell',
json: 'JSON',
md: 'Markdown',
yaml: 'YAML',
python: 'Python',
py: 'Python'
}
return languageMap[props.language] || props.language.toUpperCase()
})
</script>
<style scoped>
.code-block-wrapper {
position: relative;
margin: 2rem 0;
border-radius: 0.875rem;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.2);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
background: linear-gradient(to bottom, #1e293b, #0f172a);
}
:global(.dark) .code-block-wrapper {
border-color: rgba(71, 85, 105, 0.3);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
.code-block-header {
display: flex;
align-items: center;
justify-content: center;
padding: 0.875rem 1.5rem;
background: rgba(30, 41, 59, 0.8);
border-bottom: 1px solid rgba(71, 85, 105, 0.3);
backdrop-filter: blur(8px);
min-height: 2.75rem;
}
.code-filename {
font-size: 0.875rem;
color: #e2e8f0;
font-family: 'Courier New', monospace;
font-weight: 500;
}
.code-language {
font-size: 0.75rem;
color: #cbd5e1;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
padding: 0.25rem 0.625rem;
background: rgba(99, 102, 241, 0.2);
border-radius: 0.375rem;
border: 1px solid rgba(99, 102, 241, 0.4);
}
.code-block-content {
position: relative;
background: #0f172a;
}
.code-pre {
overflow-x: auto;
padding: 1.75rem 1.5rem;
font-size: 0.9375rem;
line-height: 1.8;
margin: 0;
background: transparent !important;
min-height: 4rem;
}
.code-pre::-webkit-scrollbar {
height: 8px;
}
.code-pre::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.5);
border-radius: 4px;
}
.code-pre::-webkit-scrollbar-thumb {
background: rgba(71, 85, 105, 0.8);
border-radius: 4px;
}
.code-pre::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.9);
}
.code-copy-button {
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.625rem;
border-radius: 0.5rem;
background: rgba(30, 41, 59, 0.95);
border: 1px solid rgba(71, 85, 105, 0.5);
cursor: pointer;
transition: all 0.2s ease;
opacity: 0;
transform: translateY(-2px);
color: #cbd5e1;
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
}
.code-copy-button.is-visible {
opacity: 1;
transform: translateY(0);
}
.code-copy-button:hover {
background: rgba(51, 65, 85, 0.95);
border-color: rgba(99, 102, 241, 0.5);
color: #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
}
.code-copy-button.is-copied {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.5);
color: #86efac;
}
.code-copy-button:active {
transform: scale(0.95);
}
</style>
<template>
<div class="code-block-wrapper" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
<!-- Header with filename and language -->
<div v-if="filename || language" class="code-block-header">
<span v-if="filename" class="code-filename">
{{ filename }}
</span>
<span v-if="language && !filename" class="code-language">
{{ languageLabel }}
</span>
</div>
<!-- Code block -->
<div class="code-block-content">
<pre :class="props.class" class="code-pre"><slot /></pre>
<!-- Copy button -->
<button type="button" :aria-label="copied ? t('blog.codeCopied') : t('blog.copyCode')" class="code-copy-button"
:class="{ 'is-visible': isHovered || copied, 'is-copied': copied }" @click="copyCode">
<UIcon :name="copied ? 'i-heroicons-check' : 'i-heroicons-clipboard-document'" class="w-4 h-4" />
</button>
</div>
</div>
</template>
+88
View File
@@ -0,0 +1,88 @@
import type { BlogPost } from '~/types/blog'
export function useBlog() {
const { locale } = useI18n()
/**
* Calculate reading time from word count
* @param content - Blog post content
* @returns Reading time in minutes
*/
const calculateReadingTime = (content: any): number => {
if (!content?.body?.children) return 0
const text = JSON.stringify(content.body.children)
const wordCount = text.split(/\s+/).length
return Math.ceil(wordCount / 200) // 200 words per minute
}
/**
* Format date for display
* @param dateString - ISO 8601 date string
* @returns Formatted date string
*/
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return new Intl.DateTimeFormat(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date)
}
/**
* Get all unique tags from posts
* @param posts - Array of blog posts
* @returns Sorted array of unique tags
*/
const extractUniqueTags = (posts: BlogPost[]): string[] => {
const tagSet = new Set<string>()
posts.forEach(post => {
post.tags?.forEach(tag => tagSet.add(tag))
})
return Array.from(tagSet).sort()
}
/**
* Get blog path for current locale
* @returns Locale-aware blog path
*/
const getBlogPath = (): string => {
return `${locale.value}/blog`
}
/**
* Filter posts by search query
*/
const filterPostsBySearch = (posts: BlogPost[], query: string): BlogPost[] => {
if (!query) return posts
const lowerQuery = query.toLowerCase()
return posts.filter(post =>
post.title?.toLowerCase().includes(lowerQuery) ||
post.description?.toLowerCase().includes(lowerQuery) ||
post.tags?.some(tag => tag.toLowerCase().includes(lowerQuery))
)
}
/**
* Filter posts by tag
* @param posts - Array of blog posts
* @param tag - Tag to filter by
* @returns Filtered array of posts
*/
const filterPostsByTag = (posts: BlogPost[], tag: string | null): BlogPost[] => {
if (!tag) return posts
return posts.filter(post => post.tags?.includes(tag))
}
return {
calculateReadingTime,
formatDate,
extractUniqueTags,
getBlogPath,
filterPostsBySearch,
filterPostsByTag
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* View Transitions API Composable
* Provides utilities for using the native View Transitions API with fallback
*/
export const useViewTransition = () => {
const router = useRouter()
/**
* Check if View Transitions API is supported
*/
const isSupported = computed(() => {
if (import.meta.server) return false
return 'startViewTransition' in document
})
/**
* Navigate with View Transition
* @param to - Route path to navigate to
* @param options - Navigation options
*/
const navigateWithTransition = async (
to: string,
options?: { replace?: boolean }
) => {
if (!isSupported.value) {
// Fallback to regular navigation
if (options?.replace) {
await router.replace(to)
} else {
await router.push(to)
}
return
}
// Use View Transitions API
const transition = (document as any).startViewTransition(async () => {
if (options?.replace) {
await router.replace(to)
} else {
await router.push(to)
}
})
try {
await transition.finished
} catch (error) {
// Transition was skipped or interrupted
console.warn('View transition interrupted:', error)
}
}
/**
* Execute a callback with View Transition
* @param callback - Function to execute during transition
*/
const withTransition = async (callback: () => void | Promise<void>) => {
if (!isSupported.value) {
// Fallback to direct execution
await callback()
return
}
const transition = (document as any).startViewTransition(async () => {
await callback()
})
try {
await transition.finished
} catch (error) {
console.warn('View transition interrupted:', error)
}
}
return {
isSupported,
navigateWithTransition,
withTransition
}
}
+8 -6
View File
@@ -1,23 +1,25 @@
<template> <template>
<div class="layout-default"> <div class="layout-default">
<!-- Default layout wrapper --> <TopNav client:only />
<slot /> <slot />
<FooterCopyright />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import TopNav from '@/components/common/TopNav.vue'
import FooterCopyright from '@/components/common/FooterCopyright.vue'
/** /**
* Default Layout * Default Layout
* *
* This is a minimal example layout demonstrating Nuxt's layout system. * Main layout for the application including TopNav and Footer.
* To use this layout in a page, add: definePageMeta({ layout: 'default' }) * This layout is used by default for all pages unless specified otherwise.
*
* Learn more: https://nuxt.com/docs/guide/directory-structure/layouts
*/ */
</script> </script>
<style scoped> <style scoped>
.layout-default { .layout-default {
/* Add default layout styles here */ /* Layout wrapper */
} }
</style> </style>
+195
View File
@@ -0,0 +1,195 @@
<script setup lang="ts">
const route = useRoute()
const { data: page, error } = await useAsyncData(route.path, () => queryContent(route.path).findOne())
if (error.value) {
throw createError({
statusCode: 404,
message: 'Page not found',
fatal: true,
})
}
useSeoMeta({
title: page.value?.title,
description: page.value?.description,
ogTitle: page.value?.title,
ogDescription: page.value?.description,
})
const { data: relatedArticles } = await useAsyncData(`content:related-articles:${page.value?.title}`, () => queryContent('/articles').where({ categories: { $in: page.value?.categories }, _extension: 'md' }).only(['_path', 'title', 'categories', 'description', 'publishedAt', 'image', 'authors']).sort({ publishedAt: -1 }).limit(3).find())
const appConfig = useAppConfig()
const runtimeConfig = useRuntimeConfig()
</script>
<template>
<UContainer
v-if="page"
>
<UPage>
<UPageHeader
class="max-w-5xl mx-auto"
:title="page.title"
:description="page.description"
>
<template #headline>
<!-- Waiting for https://github.com/nuxt/ui-pro/issues/114 -->
<!-- <dl>
<dt class="sr-only">
Categories
</dt>
<dd> -->
<template
v-for="(category, index) in page.categories"
:key="category"
>
<ULink
:to="`/categories/${category}`"
>
{{ formatCategory(category) }}
</ULink>
<span v-if="index < page.categories.length - 1">
-
</span>
</template>
<!-- </dd>
</dl> -->
</template>
<NuxtImg
v-if="page.image"
:src="page.image.src"
:alt="page.image.alt"
class="mt-8 w-full object-cover rounded-lg aspect-[16/9]"
>
<dl class="mt-8 flex justify-between text-stone-700 text-sm">
<dt class="sr-only">
Author
</dt>
<dd>
<ol class="space-x-4">
<li
v-for="author in page.authors"
:key="author.name"
>
<ULink
:to="author.social"
target="_blank"
rel="noopener"
class="flex items-center gap-2"
>
<UAvatar
:src="author.avatar"
:alt="author.name"
size="sm"
/>
<span>
{{ author.name }}
</span>
</ULink>
</li>
</ol>
</dd>
<dt class="sr-only">
Published at
</dt>
<dd>
<time :datetime="page.publishedAt">
{{ formatDate(page.publishedAt) }}
</time>
</dd>
</dl>
</nuxtimg>
</UPageHeader>
<div class="mt-8 max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-[96px_768px_1fr]">
<div class="lg:px-8 flex lg:flex-col lg:items-end gap-2">
<UTooltip text="Share on X">
<UButton
:to="`https://twitter.com/share?text=${page.title}&url=${runtimeConfig.app.name}${page._path}`"
target="_blank"
icon="i-simple-icons-x"
size="sm"
color="primary"
square
variant="ghost"
/>
</UTooltip>
<UTooltip text="Share on Facebook">
<UButton
:to="`https://www.facebook.com/sharer/sharer.php?u=${runtimeConfig.app.name}${page._path}&t=${page.title}`"
target="_blank"
icon="i-simple-icons-facebook"
size="sm"
color="primary"
square
variant="ghost"
/>
</UTooltip>
<UTooltip text="Share on LinkedIn">
<UButton
:to="`https://www.linkedin.com/shareArticle?url=${runtimeConfig.app.name}${page._path}&title=${page.title}`"
target="_blank"
icon="i-simple-icons-linkedin"
size="sm"
color="primary"
square
variant="ghost"
/>
</UTooltip>
</div>
<div class="mt-8 lg:mt-0 w-full">
<UPageBody
prose
:ui="{ wrapper: 'mt-0' }"
>
<ContentRenderer :value="page" />
</UPageBody>
</div>
<div class="row-start-2 lg:row-start-1 lg:col-start-3 lg:px-8 space-y-8">
<UButton
v-bind="appConfig.page.article.cta"
color="primary"
size="lg"
:ui="{ base: 'w-full justify-center' }"
class="hidden lg:inline-flex"
/>
<UDivider />
<UContentToc
:links="page.body?.toc?.links"
:ui="{ wrapper: 'top-4', container: { base: 'py-0 pb-3 lg:py-0 lg:pb-8' } }"
/>
<UDivider class="lg:hidden" />
</div>
</div>
<section v-if="relatedArticles">
<h2 class="text-2xl text-stone-900 font-bold">
Related Articles
</h2>
<div class="mt-3 border-b border-stone-200" />
<UPageGrid class="mt-8">
<ArticleCard
v-for="article in relatedArticles"
:key="article._path"
:to="article._path!"
:title="article.title!"
:description="article.description"
:date="article.publishedAt"
:image="article.image"
:authors="article.authors"
/>
</UPageGrid>
</section>
</UPage>
</UContainer>
</template>
+153 -3
View File
@@ -1,7 +1,157 @@
<script setup lang="ts">
const { locale, t } = useI18n()
const localePath = useLocalePath()
const route = useRoute()
const slug = Array.isArray(route.params.slug) ? route.params.slug : [route.params.slug]
// Fetch current post
const { data: post } = await useAsyncData(`blog-post-${slug.join('/')}`, async () => {
const posts = await queryCollection('blog')
.where('path', '=', `/${locale.value}/blog/${slug.join('/')}`)
.first()
return posts
})
if (!post.value) {
throw createError({
statusCode: 404,
message: 'Blog post not found',
fatal: true
})
}
// Fetch all posts for prev/next navigation
const { data: allPosts } = await useAsyncData(`blog-posts-nav-${locale.value}`, async () => {
const posts = await queryCollection('blog')
.where('draft', '<>', true)
.order('date', 'DESC')
.all()
// Filter by locale and posts without draft field
return posts.filter((p: any) =>
p.path?.startsWith(`/${locale.value}/blog/`) &&
(p.draft === false || p.draft === undefined)
)
})
// Calculate adjacent posts
const currentIndex = computed(() => {
if (!allPosts.value || !post.value) return -1
return allPosts.value.findIndex((p: any) => p.path === post.value!.path)
})
const prevPost = computed(() => {
if (currentIndex.value === -1 || !allPosts.value) return null
return allPosts.value[currentIndex.value + 1] || null
})
const nextPost = computed(() => {
if (currentIndex.value === -1 || !allPosts.value) return null
return allPosts.value[currentIndex.value - 1] || null
})
// SEO meta tags
const siteUrl = 'https://aliarghyani.vercel.app' // TODO: Move to runtime config
// Custom meta tags
if (post.value) {
const postData = post.value as any
useSeoMeta({
title: `${postData.title} | ${t('blog.title')}`,
description: postData.description,
ogTitle: postData.title,
ogDescription: postData.description,
ogImage: postData.image || '/img/blog/default-cover.jpg',
ogType: 'article',
ogUrl: `${siteUrl}${postData.path}`,
twitterCard: 'summary_large_image',
twitterTitle: postData.title,
twitterDescription: postData.description,
twitterImage: postData.image || '/img/blog/default-cover.jpg',
articlePublishedTime: postData.date,
articleModifiedTime: postData.updatedAt || postData.date,
articleAuthor: [postData.author || 'Ali Arghyani'],
articleTag: postData.tags
})
// JSON-LD structured data
useHead({
script: [
{
type: 'application/ld+json',
textContent: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: postData.title,
description: postData.description,
image: postData.image ? `${siteUrl}${postData.image}` : `${siteUrl}/img/blog/default-cover.jpg`,
datePublished: postData.date,
dateModified: postData.updatedAt || postData.date,
author: {
'@type': 'Person',
name: postData.author || 'Ali Arghyani'
},
publisher: {
'@type': 'Person',
name: 'Ali Arghyani'
}
})
}
]
})
}
</script>
<template> <template>
<UContainer> <UContainer>
<UAlert color="yellow" variant="soft" title="Blog disabled"> <div v-if="post" class="pt-24 pb-12">
Nuxt Content is temporarily disabled. Blog post rendering will be restored later. <!-- Breadcrumb Navigation -->
</UAlert> <UBreadcrumb :links="[
{ label: t('nav.home'), to: localePath('/') },
{ label: t('blog.title'), to: localePath('/blog') },
{ label: (post as any).title }
]" class="mb-6" />
<!-- Back to Blog Link -->
<NuxtLink :to="localePath('/blog')"
class="inline-flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors mb-8">
<UIcon name="i-heroicons-arrow-left" class="w-4 h-4" />
{{ t('blog.backToBlog') }}
</NuxtLink>
<!-- Mobile TOC -->
<div v-if="(post as any).body?.toc?.links?.length" class="lg:hidden mb-8">
<BlogTableOfContents :toc="(post as any).body.toc" :mobile="true" />
</div>
<!-- Main Content Layout -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-8 lg:gap-12">
<!-- Main Content -->
<div class="min-w-0 overflow-x-hidden">
<!-- Blog Post Metadata -->
<BlogPost :post="post" />
<!-- Content Renderer -->
<article :dir="locale === 'fa' ? 'rtl' : 'ltr'"
:class="['blog-content', locale === 'fa' ? 'blog-content-rtl' : 'blog-content-ltr']"
suppressHydrationWarning>
<ContentRenderer v-if="(post as any).body" :value="(post as any).body" />
</article>
<!-- Blog Navigation (Prev/Next) -->
<BlogNavigation :prev="prevPost" :next="nextPost" />
</div>
<!-- Sidebar: Table of Contents (Desktop) -->
<aside v-if="(post as any).body?.toc?.links?.length" class="hidden lg:block">
<UContentToc :links="(post as any).body.toc.links" :title="t('blog.tableOfContents')" color="primary"
highlight :ui="{
root: 'sticky top-24',
container: 'bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4'
}" />
</aside>
</div>
</div>
</UContainer> </UContainer>
</template> </template>
+63 -5
View File
@@ -1,5 +1,5 @@
<template> <template>
<section class="py-10"> <section class="pt-24 pb-10">
<UContainer> <UContainer>
<div class="mb-8 flex flex-wrap items-center justify-between gap-4"> <div class="mb-8 flex flex-wrap items-center justify-between gap-4">
<div> <div>
@@ -8,15 +8,73 @@
</div> </div>
</div> </div>
<div class="rounded-xl border border-dashed border-gray-300 p-8 text-center text-gray-500 dark:border-gray-700 dark:text-gray-300"> <BlogSearch v-model="searchQuery" />
Nuxt Content is temporarily disabled. Blog will be back soon.
<BlogTagFilter v-model="selectedTag" :tags="allTags" class="my-6" />
<div v-if="filteredPosts.length > 0" class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<BlogCard v-for="post in filteredPosts" :key="(post as any).path" :post="post" />
</div> </div>
<BlogEmpty v-else />
</UContainer> </UContainer>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { locale, t } = useI18n() import type { BlogPost } from '~/types/blog'
// Blog listing requires @nuxt/content which is disabled for now const { t, locale } = useI18n()
const { extractUniqueTags, filterPostsBySearch, filterPostsByTag } = useBlog()
// Fetch posts using queryCollection (Nuxt Content v3 API)
const { data: posts } = await useAsyncData<any[]>(
`blog-posts-${locale.value}`,
async () => {
try {
// Use queryCollection for Nuxt Content v3
const result = await queryCollection('blog').all()
if (!result || result.length === 0) {
return []
}
// Filter by locale and draft status, then sort by date
return result
.filter((post: any) =>
post.path?.startsWith(`/${locale.value}/blog/`) && post.draft !== true
)
.sort((a: any, b: any) => new Date(b.date).getTime() - new Date(a.date).getTime())
} catch (error) {
console.error('Error fetching blog posts:', error)
return []
}
},
{
default: () => [],
watch: [locale],
server: true,
lazy: false
}
)
// Extract unique tags from all posts
const allTags = computed(() => {
return posts.value && Array.isArray(posts.value) ? extractUniqueTags(posts.value) : []
})
// Reactive refs for search and filter
const searchQuery = ref('')
const selectedTag = ref<string | null>(null)
// Filtered posts based on search and tag
const filteredPosts = computed<BlogPost[]>(() => {
if (!posts.value || !Array.isArray(posts.value) || posts.value.length === 0) return []
let filtered: BlogPost[] = [...posts.value]
filtered = filterPostsBySearch(filtered, searchQuery.value)
filtered = filterPostsByTag(filtered, selectedTag.value)
return filtered
})
</script> </script>
+24
View File
@@ -0,0 +1,24 @@
export interface BlogPost {
// Required fields
title: string
description: string
date: string // ISO 8601 format
tags: string[]
path: string // Nuxt Content v3 uses 'path' instead of '_path'
// Optional fields
image?: string // Cover image path
author?: string
draft?: boolean
updatedAt?: string
// Custom SEO
head?: {
title?: string
description?: string
image?: string
}
// Body with TOC (MarkdownRoot from Nuxt Content)
body?: any
}
+21
View File
@@ -0,0 +1,21 @@
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
export default defineContentConfig({
collections: {
blog: defineCollection({
type: 'page',
source: '**/*.md',
schema: z.object({
title: z.string(),
description: z.string(),
date: z.string(),
tags: z.array(z.string()),
image: z.string().optional(),
author: z.string().optional(),
draft: z.boolean().optional(),
updatedAt: z.string().optional()
})
})
}
})
+147
View File
@@ -0,0 +1,147 @@
# Blog Content Authoring Guide
This directory contains all blog posts for the website in multiple languages.
## Directory Structure
```
content/
├── en/
│ └── blog/
│ ├── post-1.md
│ └── post-2.md
└── fa/
└── blog/
├── post-1.md
└── post-2.md
```
## Frontmatter Schema
Each blog post must include the following frontmatter:
```yaml
---
title: "Your Post Title" # Required: Post title
description: "Brief description" # Required: Post description (for SEO)
date: "2024-11-09" # Required: Publication date (YYYY-MM-DD)
tags: ["tag1", "tag2"] # Required: Array of tags
image: "/img/blog/cover.jpg" # Optional: Cover image path (if omitted, shows gradient placeholder)
author: "Author Name" # Optional: Author name
draft: false # Optional: Set to true to hide post
---
```
**Note:** If you don't provide an `image` or if the image fails to load, a beautiful gradient placeholder with the post title will be displayed automatically.
## Markdown Features
### Basic Formatting
```markdown
# Heading 1
## Heading 2
### Heading 3
**Bold text**
*Italic text*
- Bullet list
- Item 2
1. Numbered list
2. Item 2
[Link text](https://example.com)
```
### Code Blocks
Use triple backticks with language identifier:
\`\`\`typescript
const greeting = (name: string) => {
console.log(`Hello, ${name}!`)
}
\`\`\`
### Images
```markdown
![Alt text](/img/blog/image.jpg)
```
## MDC Components
### Callout Boxes
```markdown
::blog-callout{type="info" title="Note"}
This is an informational callout box.
::
::blog-callout{type="warning" title="Warning"}
This is a warning callout.
::
::blog-callout{type="success" title="Success"}
This is a success callout.
::
::blog-callout{type="error" title="Error"}
This is an error callout.
::
```
### Inline Alerts
```markdown
::alert{type="info"}
This is an inline alert.
::
```
## Best Practices
1. **Use descriptive titles**: Make titles clear and SEO-friendly
2. **Write good descriptions**: Keep descriptions between 120-160 characters
3. **Choose relevant tags**: Use 3-5 tags per post
4. **Optimize images**: Use WebP format and appropriate dimensions
5. **Add alt text**: Always include alt text for images
6. **Use headings**: Structure content with proper heading hierarchy
7. **Test both languages**: Verify RTL layout for Persian posts
8. **Preview before publishing**: Check formatting and layout
## Publishing Workflow
1. Create a new `.md` file in the appropriate language directory
2. Add complete frontmatter
3. Write your content using Markdown
4. Set `draft: true` while working
5. Preview in development mode
6. Set `draft: false` when ready to publish
7. Commit and push to deploy
## Cover Images
- Cover images are **optional** - posts without images will show a gradient placeholder
- Store cover images in `public/img/blog/`
- Use descriptive filenames (e.g., `nuxt-content-guide.jpg`)
- Recommended dimensions: 1200x630px
- Supported formats: JPG, PNG, WebP
- If an image fails to load, a gradient placeholder is shown automatically
## RTL Content (Persian)
For Persian blog posts:
- Content direction is automatically set to RTL
- Code blocks remain LTR
- Use Persian fonts (Vazirmatn)
- Test layout in both desktop and mobile views
## Need Help?
- Check existing posts for examples
- Review the design document in `.kiro/specs/nuxt-content-blog/design.md`
- Test MDC components in development mode
+14
View File
@@ -0,0 +1,14 @@
---
title: "This is a Draft Post"
description: "This post is in draft mode and should not appear in production."
date: "2024-11-10"
tags: ["draft", "test"]
author: "Ali Arghyani"
draft: true
---
# Draft Post
This is a draft post that should only be visible in development mode.
It will be filtered out in production using the `draft: true` frontmatter field.
@@ -0,0 +1,107 @@
---
title: "Getting Started with Nuxt Content"
description: "Learn how to build a powerful blog with Nuxt Content v3, featuring markdown support, syntax highlighting, and Vue component integration."
date: "2024-11-09"
tags: ["nuxt", "vue", "typescript", "tutorial"]
author: "Ali Arghyani"
draft: false
---
# Getting Started with Nuxt Content
Nuxt Content is a powerful file-based CMS that allows you to write content in Markdown, YAML, CSV, or JSON and query it with a MongoDB-like API. In this tutorial, we'll explore how to set up and use Nuxt Content v3 in your Nuxt 4 application.
## Why Nuxt Content?
Nuxt Content offers several advantages for content-driven applications:
- **File-based**: Write content in Markdown files with Git version control
- **Type-safe**: Full TypeScript support with auto-generated types
- **Powerful queries**: MongoDB-like API for filtering and sorting
- **Syntax highlighting**: Built-in code highlighting with Shiki
- **MDC syntax**: Embed Vue components directly in Markdown
## Installation
Installing Nuxt Content is straightforward:
```bash
pnpm add @nuxt/content
```
Then add it to your `nuxt.config.ts`:
```typescript
export default defineNuxtConfig({
modules: ['@nuxt/content']
})
```
## Creating Content
Create a `content/` directory in your project root and start writing Markdown files:
```markdown
---
title: "My First Post"
description: "This is my first blog post"
date: "2024-11-09"
---
# Hello World
This is my first post using Nuxt Content!
```
## Querying Content
Use the `queryContent()` composable to fetch your content:
```vue
<script setup>
const { data: posts } = await useAsyncData('posts', () =>
queryContent('blog')
.sort({ date: -1 })
.find()
)
</script>
```
## Rendering Content
Use the `ContentRenderer` component to render your Markdown:
```vue
<template>
<ContentRenderer :value="post" />
</template>
```
## Advanced Features
### Code Highlighting
Nuxt Content uses Shiki for beautiful syntax highlighting:
```javascript
// This code will be highlighted automatically
const greeting = (name) => {
console.log(`Hello, ${name}!`)
}
```
### MDC Components
You can use Vue components in your Markdown:
```markdown
::alert{type="info"}
This is an informational alert!
::
```
## Conclusion
Nuxt Content provides a powerful and flexible way to manage content in your Nuxt applications. With its file-based approach, powerful querying capabilities, and seamless Vue integration, it's perfect for blogs, documentation sites, and content-heavy applications.
Happy coding! 🚀
+183
View File
@@ -0,0 +1,183 @@
---
title: "Building Beautiful UIs with Nuxt UI"
description: "Explore Nuxt UI components and learn how to create stunning, accessible user interfaces with minimal effort."
date: "2024-11-08"
tags: ["nuxt", "ui", "design", "components"]
author: "Ali Arghyani"
draft: false
---
# Building Beautiful UIs with Nuxt UI
Nuxt UI is a comprehensive UI library built on top of Tailwind CSS and Headless UI, providing a collection of fully styled and customizable components for Nuxt 3 applications.
## Why Nuxt UI?
Nuxt UI stands out for several reasons:
- **Fully Typed**: Complete TypeScript support with IntelliSense
- **Accessible**: Built with accessibility in mind using Headless UI
- **Customizable**: Easy theming with Tailwind CSS
- **Dark Mode**: Built-in dark mode support
- **Icons**: Integrated with Iconify for thousands of icons
::blog-callout{type="info" title="Pro Tip"}
Nuxt UI components are designed to work seamlessly with Nuxt's auto-import feature, so you can use them without explicit imports!
::
## Getting Started
Install Nuxt UI in your project:
```bash
pnpm add @nuxt/ui
```
Add it to your `nuxt.config.ts`:
```typescript
export default defineNuxtConfig({
modules: ['@nuxt/ui']
})
```
## Essential Components
### Buttons
Buttons are the foundation of any UI. Nuxt UI provides flexible button components:
```vue
<template>
<div class="flex gap-2">
<UButton>Default</UButton>
<UButton color="primary">Primary</UButton>
<UButton variant="outline">Outline</UButton>
<UButton icon="i-heroicons-rocket-launch">With Icon</UButton>
</div>
</template>
```
### Cards
Cards are perfect for displaying content:
```vue
<template>
<UCard>
<template #header>
<h3>Card Title</h3>
</template>
<p>Card content goes here</p>
<template #footer>
<UButton>Action</UButton>
</template>
</UCard>
</template>
```
### Forms
Build forms quickly with validation:
```vue
<script setup>
const state = reactive({
email: '',
password: ''
})
const schema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
</script>
<template>
<UForm :schema="schema" :state="state" @submit="onSubmit">
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UButton type="submit">Submit</UButton>
</UForm>
</template>
```
## Theming
Customize your app's appearance with the `app.config.ts`:
```typescript
export default defineAppConfig({
ui: {
primary: 'indigo',
gray: 'slate',
button: {
rounded: 'rounded-full'
}
}
})
```
::alert{type="success"}
All Nuxt UI components respect your theme configuration automatically!
::
## Dark Mode
Dark mode is built-in and works out of the box:
```vue
<template>
<UButton @click="toggleDarkMode">
Toggle Dark Mode
</UButton>
</template>
<script setup>
const colorMode = useColorMode()
const toggleDarkMode = () => {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
</script>
```
## Icons
Access thousands of icons from Iconify:
```vue
<template>
<div class="flex gap-2">
<UIcon name="i-heroicons-home" />
<UIcon name="i-heroicons-user" />
<UIcon name="i-heroicons-cog" />
</div>
</template>
```
::blog-callout{type="warning" title="Performance Note"}
Icons are automatically optimized and only the ones you use are included in your bundle!
::
## Best Practices
1. **Use Composition API**: Leverage Vue 3's Composition API for better code organization
2. **Type Everything**: Take advantage of TypeScript for better DX
3. **Customize Wisely**: Override only what you need in `app.config.ts`
4. **Accessibility First**: Always test with keyboard navigation and screen readers
5. **Performance**: Use lazy loading for heavy components
## Conclusion
Nuxt UI provides a solid foundation for building modern web applications. With its comprehensive component library, excellent TypeScript support, and seamless Nuxt integration, you can focus on building features rather than styling components.
Start building beautiful UIs today! 🎨
@@ -0,0 +1,132 @@
---
title: "TypeScript Best Practices for Vue 3"
description: "Discover essential TypeScript patterns and best practices for building type-safe Vue 3 applications with Composition API."
date: "2024-11-08"
tags: ["typescript", "vue", "best-practices", "composition-api"]
author: "Ali Arghyani"
draft: false
---
# TypeScript Best Practices for Vue 3
TypeScript has become an essential tool for building robust Vue 3 applications. In this guide, we'll explore best practices for leveraging TypeScript's type system with Vue 3's Composition API.
## Type-Safe Props
Define props with proper TypeScript interfaces:
```vue
<script setup lang="ts">
interface Props {
title: string
count?: number
items: string[]
}
const props = defineProps<Props>()
</script>
```
## Typed Composables
Create reusable composables with full type safety:
```typescript
export function useCounter(initialValue = 0) {
const count = ref<number>(initialValue)
const increment = (): void => {
count.value++
}
const decrement = (): void => {
count.value--
}
return {
count: readonly(count),
increment,
decrement
}
}
```
## Generic Components
Build flexible components with generics:
```vue
<script setup lang="ts" generic="T extends { id: string }">
interface Props {
items: T[]
onSelect: (item: T) => void
}
const props = defineProps<Props>()
</script>
```
## Type-Safe Event Emits
Define emits with proper typing:
```vue
<script setup lang="ts">
interface Emits {
(e: 'update', value: string): void
(e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
</script>
```
## Utility Types
Leverage TypeScript utility types:
```typescript
// Pick specific properties
type UserPreview = Pick<User, 'id' | 'name' | 'email'>
// Make all properties optional
type PartialUser = Partial<User>
// Make all properties required
type RequiredUser = Required<User>
// Exclude properties
type UserWithoutPassword = Omit<User, 'password'>
```
## Type Guards
Implement type guards for runtime type checking:
```typescript
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
)
}
```
## Async Data Typing
Type your async data properly:
```typescript
const { data, pending, error } = await useAsyncData<User[]>(
'users',
() => $fetch('/api/users')
)
```
## Conclusion
TypeScript enhances Vue 3 development by providing type safety, better IDE support, and improved code maintainability. By following these best practices, you'll build more robust and maintainable applications.
Remember: TypeScript is a tool to help you, not hinder you. Start simple and gradually add more type safety as needed.
@@ -0,0 +1,111 @@
---
title: "آشنایی با Nuxt Content"
description: "یاد بگیرید چگونه با Nuxt Content یک وبلاگ قدرتمند بسازید که از مارک‌داون، هایلایت کد و کامپوننت‌های Vue پشتیبانی می‌کند."
date: "2024-11-09"
tags: ["nuxt", "vue", "آموزش", "فارسی"]
author: "علی ارغیانی"
draft: false
---
# آشنایی با Nuxt Content
Nuxt Content یک سیستم مدیریت محتوای فایل‌محور قدرتمند است که به شما امکان می‌دهد محتوای خود را به صورت Markdown، YAML، CSV یا JSON بنویسید و با یک API شبیه MongoDB آن را جستجو کنید.
::blog-callout{type="info"}
این مقاله یک راهنمای جامع برای شروع کار با Nuxt Content است. اگر تازه شروع کرده‌اید، این مقاله برای شما مناسب است!
::
## چرا Nuxt Content؟
Nuxt Content مزایای متعددی برای برنامه‌های محتوا-محور ارائه می‌دهد:
- **فایل-محور**: محتوا را در فایل‌های Markdown با کنترل نسخه Git بنویسید
- **Type-safe**: پشتیبانی کامل از TypeScript با تایپ‌های خودکار
- **جستجوی قدرتمند**: API شبیه MongoDB برای فیلتر و مرتب‌سازی
- **هایلایت کد**: هایلایت خودکار کد با Shiki
- **سینتکس MDC**: استفاده از کامپوننت‌های Vue مستقیماً در Markdown
## نصب
نصب Nuxt Content بسیار ساده است:
```bash
pnpm add @nuxt/content
```
سپس آن را به `nuxt.config.ts` اضافه کنید:
```typescript
export default defineNuxtConfig({
modules: ['@nuxt/content']
})
```
## ایجاد محتوا
یک دایرکتوری `content/` در ریشه پروژه خود ایجاد کنید و شروع به نوشتن فایل‌های Markdown کنید:
```markdown
---
title: "اولین پست من"
description: "این اولین پست وبلاگ من است"
date: "2024-11-09"
---
# سلام دنیا
این اولین پست من با استفاده از Nuxt Content است!
```
## جستجوی محتوا
از composable `queryContent()` برای دریافت محتوا استفاده کنید:
```vue
<script setup>
const { data: posts } = await useAsyncData('posts', () =>
queryContent('blog')
.sort({ date: -1 })
.find()
)
</script>
```
## رندر کردن محتوا
از کامپوننت `ContentRenderer` برای رندر Markdown استفاده کنید:
```vue
<template>
<ContentRenderer :value="post" />
</template>
```
## ویژگی‌های پیشرفته
### هایلایت کد
Nuxt Content از Shiki برای هایلایت زیبای کد استفاده می‌کند:
```javascript
// این کد به صورت خودکار هایلایت می‌شود
const greeting = (name) => {
console.log(`سلام، ${name}!`)
}
```
### کامپوننت‌های MDC
می‌توانید از کامپوننت‌های Vue در Markdown خود استفاده کنید:
```markdown
::alert{type="info"}
این یک هشدار اطلاعاتی است!
::
```
## نتیجه‌گیری
Nuxt Content یک راه قدرتمند و انعطاف‌پذیر برای مدیریت محتوا در برنامه‌های Nuxt شما فراهم می‌کند. با رویکرد فایل-محور، قابلیت‌های جستجوی قدرتمند و یکپارچگی یکپارچه با Vue، برای وبلاگ‌ها، سایت‌های مستندات و برنامه‌های محتوا-محور عالی است.
کدنویسی خوشحالی! 🚀
+180
View File
@@ -0,0 +1,180 @@
---
title: "نکات کار با Tailwind CSS در پروژه‌های RTL"
description: "راهنمای جامع برای استفاده از Tailwind CSS در پروژه‌های راست به چپ و چالش‌های رایج"
date: "2024-11-08"
tags: ["tailwind", "rtl", "css", "فارسی"]
author: "علی ارغیانی"
draft: false
---
# نکات کار با Tailwind CSS در پروژه‌های RTL
استفاده از Tailwind CSS در پروژه‌های راست به چپ (RTL) می‌تواند چالش‌برانگیز باشد. در این مقاله، نکات و راهکارهای عملی برای کار با Tailwind در پروژه‌های فارسی و عربی را بررسی می‌کنیم.
## چالش‌های RTL
پروژه‌های RTL با چالش‌های خاصی روبرو هستند:
- **جهت‌دهی**: تغییر جهت layout از چپ به راست
- **Margin و Padding**: تبدیل left/right به start/end
- **فونت‌ها**: انتخاب فونت‌های مناسب فارسی
- **کامپوننت‌ها**: سازگاری کامپوننت‌های third-party
::blog-callout{type="info" title="نکته مهم"}
Tailwind CSS از نسخه 3 به بعد پشتیبانی بهتری از RTL دارد!
::
## تنظیمات اولیه
ابتدا باید attribute `dir` را به صورت داینامیک تنظیم کنید:
```vue
<template>
<html :dir="locale === 'fa' ? 'rtl' : 'ltr'">
<body>
<NuxtPage />
</body>
</html>
</template>
<script setup>
const { locale } = useI18n()
</script>
```
## استفاده از Logical Properties
به جای `left` و `right` از `start` و `end` استفاده کنید:
```html
<!-- ❌ اشتباه -->
<div class="ml-4 pr-2">محتوا</div>
<!-- ✅ درست -->
<div class="ms-4 pe-2">محتوا</div>
```
کلاس‌های Logical در Tailwind:
- `ms-*` به جای `ml-*` (margin-start)
- `me-*` به جای `mr-*` (margin-end)
- `ps-*` به جای `pl-*` (padding-start)
- `pe-*` به جای `pr-*` (padding-end)
## تنظیم فونت‌های فارسی
فونت‌های فارسی نیاز به تنظیمات خاصی دارند:
```typescript
// nuxt.config.ts
export default defineNuxtConfig({
fonts: {
families: [
{
name: 'Vazirmatn',
provider: 'google',
weights: [400, 500, 600, 700]
}
]
}
})
```
در CSS:
```css
[dir="rtl"] {
font-family: 'Vazirmatn', sans-serif;
}
[dir="ltr"] {
font-family: 'Inter', sans-serif;
}
```
## مدیریت Flexbox و Grid
Flexbox و Grid به صورت خودکار با RTL سازگار می‌شوند:
```html
<div class="flex justify-start gap-4">
<!-- در RTL از راست شروع می‌شود -->
<div>آیتم 1</div>
<div>آیتم 2</div>
<div>آیتم 3</div>
</div>
```
::alert{type="success"}
Flexbox و Grid در Tailwind به صورت خودکار با dir="rtl" سازگار می‌شوند!
::
## کلاس‌های شرطی RTL/LTR
برای استایل‌های خاص RTL یا LTR:
```html
<div class="ltr:text-left rtl:text-right">
متن دو زبانه
</div>
```
## مشکلات رایج و راه‌حل‌ها
### 1. آیکون‌ها
برخی آیکون‌ها نیاز به چرخش در RTL دارند:
```vue
<template>
<UIcon
name="i-heroicons-arrow-right"
:class="{ 'rtl:rotate-180': true }"
/>
</template>
```
### 2. بلوک‌های کد
بلوک‌های کد باید همیشه LTR باشند:
```css
pre, code {
direction: ltr !important;
text-align: left !important;
}
```
### 3. اعداد انگلیسی
اعداد انگلیسی در متن فارسی:
```html
<span class="font-mono ltr:inline-block">
123
</span>
```
## بهترین روش‌ها
1. **از Logical Properties استفاده کنید**: همیشه `start/end` به جای `left/right`
2. **تست کنید**: هر دو حالت RTL و LTR را تست کنید
3. **فونت مناسب**: از فونت‌های استاندارد فارسی استفاده کنید
4. **کامپوننت‌های سازگار**: کامپوننت‌هایی که RTL را پشتیبانی می‌کنند انتخاب کنید
5. **مستندسازی**: تغییرات RTL را مستند کنید
::blog-callout{type="warning" title="توجه"}
همیشه پروژه خود را در هر دو حالت RTL و LTR تست کنید!
::
## ابزارهای مفید
- **Tailwind RTL Plugin**: پلاگین رسمی Tailwind برای RTL
- **PostCSS RTL**: تبدیل خودکار CSS به RTL
- **Browser DevTools**: برای debug کردن مشکلات layout
## نتیجه‌گیری
کار با Tailwind CSS در پروژه‌های RTL با رعایت نکات ذکر شده می‌تواند بسیار ساده و کارآمد باشد. استفاده از Logical Properties و تست مداوم کلید موفقیت است.
موفق باشید! 🎨
+178
View File
@@ -0,0 +1,178 @@
---
title: "راهنمای Composition API در Vue 3"
description: "آموزش کامل Composition API در Vue 3 با مثال‌های کاربردی و بهترین روش‌های پیاده‌سازی."
date: "2024-11-07"
tags: ["vue", "composition-api", "آموزش", "فارسی"]
author: "علی ارغیانی"
draft: false
---
# راهنمای Composition API در Vue 3
Composition API یکی از مهم‌ترین ویژگی‌های Vue 3 است که روش جدیدی برای سازماندهی و استفاده مجدد از منطق کامپوننت‌ها ارائه می‌دهد.
## چرا Composition API؟
Composition API مشکلات Options API را حل می‌کند:
- **سازماندهی بهتر**: منطق مرتبط را در کنار هم نگه دارید
- **استفاده مجدد**: composable ها را به راحتی به اشتراک بگذارید
- **Type Safety**: پشتیبانی بهتر از TypeScript
- **خوانایی**: کد تمیزتر و قابل فهم‌تر
## مفاهیم پایه
### Reactive State
برای ایجاد state واکنش‌پذیر از `ref` یا `reactive` استفاده کنید:
```vue
<script setup>
import { ref, reactive } from 'vue'
// با ref
const count = ref(0)
// با reactive
const state = reactive({
name: 'علی',
age: 25
})
</script>
```
### Computed Properties
برای مقادیر محاسبه‌شده از `computed` استفاده کنید:
```vue
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('علی')
const lastName = ref('ارغیانی')
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
</script>
```
### Watchers
برای نظارت بر تغییرات از `watch` یا `watchEffect` استفاده کنید:
```vue
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newValue, oldValue) => {
console.log(`تغییر از ${oldValue} به ${newValue}`)
})
</script>
```
## Composables
composable ها توابع قابل استفاده مجددی هستند که منطق stateful را کپسوله می‌کنند:
```typescript
// composables/useCounter.ts
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => {
count.value++
}
const decrement = () => {
count.value--
}
const reset = () => {
count.value = initialValue
}
return {
count: readonly(count),
increment,
decrement,
reset
}
}
```
استفاده از composable:
```vue
<script setup>
const { count, increment, decrement } = useCounter(10)
</script>
<template>
<div>
<p>شمارنده: {{ count }}</p>
<button @click="increment">افزایش</button>
<button @click="decrement">کاهش</button>
</div>
</template>
```
## Lifecycle Hooks
از lifecycle hooks در `<script setup>` استفاده کنید:
```vue
<script setup>
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
console.log('کامپوننت mount شد')
})
onUnmounted(() => {
console.log('کامپوننت unmount شد')
})
</script>
```
## بهترین روش‌ها
### 1. استفاده از `<script setup>`
این سینتکس مختصرتر و بهینه‌تر است:
```vue
<script setup>
// کد شما اینجا
</script>
```
### 2. سازماندهی Composables
composable ها را در دایرکتوری `composables/` قرار دهید:
```
composables/
├── useAuth.ts
├── useCounter.ts
└── useFetch.ts
```
### 3. نام‌گذاری
composable ها را با `use` شروع کنید:
```typescript
export function useMyFeature() {
// ...
}
```
## نتیجه‌گیری
Composition API روش قدرتمندی برای نوشتن کامپوننت‌های Vue است که کد را قابل نگهداری‌تر، قابل استفاده مجددتر و type-safe تر می‌کند.
با تمرین و استفاده مداوم، Composition API به بخش طبیعی از توسعه Vue شما تبدیل خواهد شد.
+18 -2
View File
@@ -99,12 +99,28 @@
"skills": "Skills", "skills": "Skills",
"blog": "Blog", "blog": "Blog",
"contact": "Contact", "contact": "Contact",
"theme": "Theme" "theme": "Theme",
"languageSelector": "Language selector"
}, },
"blog": { "blog": {
"title": "Blog",
"explore": "Stories & notes", "explore": "Stories & notes",
"empty": "No posts yet. Check back soon!", "empty": "No posts yet. Check back soon!",
"readMore": "Read article" "readMore": "Read article",
"readingTime": "{minutes} min read",
"publishedOn": "Published on",
"updatedOn": "Updated on",
"backToBlog": "Back to Blog",
"previousPost": "Previous",
"nextPost": "Next",
"tableOfContents": "Table of Contents",
"searchPlaceholder": "Search posts...",
"filterByTag": "Filter by tag",
"allPosts": "All posts",
"noResults": "No posts found matching your search",
"copyCode": "Copy code",
"codeCopied": "Copied!",
"subscribe": "Subscribe via RSS"
}, },
"common": { "common": {
"present": "Present", "present": "Present",
+18 -2
View File
@@ -69,12 +69,28 @@
"skills": "مهارت‌ها", "skills": "مهارت‌ها",
"blog": "وبلاگ", "blog": "وبلاگ",
"contact": "ارتباط", "contact": "ارتباط",
"theme": "تم" "theme": "تم",
"languageSelector": "انتخاب زبان"
}, },
"blog": { "blog": {
"title": "وبلاگ",
"explore": "داستان‌ها و یادداشت‌ها", "explore": "داستان‌ها و یادداشت‌ها",
"empty": "فعلاً نوشته‌ای وجود ندارد.", "empty": "فعلاً نوشته‌ای وجود ندارد.",
"readMore": "مطالعه" "readMore": "مطالعه",
"readingTime": "{minutes} دقیقه مطالعه",
"publishedOn": "منتشر شده در",
"updatedOn": "به‌روزرسانی شده در",
"backToBlog": "بازگشت به وبلاگ",
"previousPost": "قبلی",
"nextPost": "بعدی",
"tableOfContents": "فهرست مطالب",
"searchPlaceholder": "جستجوی مقالات...",
"filterByTag": "فیلتر بر اساس برچسب",
"allPosts": "همه مقالات",
"noResults": "نتیجه‌ای یافت نشد",
"copyCode": "کپی کد",
"codeCopied": "کپی شد!",
"subscribe": "اشتراک از طریق RSS"
}, },
"common": { "common": {
"present": "اکنون", "present": "اکنون",
+26
View File
@@ -0,0 +1,26 @@
{
"status": "ok",
"problems": [],
"recommendations": [],
"details": {
"outputDir": ".output/public",
"foundRoutes": [
"index.html",
"blog/index.html",
"fa/index.html",
"fa/blog/index.html"
],
"missingRoutes": [],
"buildAssets": {
"totalFiles": 149,
"jsFiles": 138,
"cssFiles": 8,
"metaFiles": 1
},
"relativePathIssues": [],
"configIssues": [],
"hasSpaFallback": true,
"has404Page": true,
"hasVercelConfig": true
}
}
+313
View File
@@ -0,0 +1,313 @@
/**
* Nuxt SSG Diagnostic Tool
* Analyzes build output for common SSG/SSR/hydration issues
*/
const fs = require('fs');
const path = require('path');
const OUTPUT_DIR = '.output/public';
const DIST_DIR = 'dist';
const CONFIG_FILE = 'nuxt.config.ts';
const diagnostics = {
status: 'ok',
problems: [],
recommendations: [],
details: {}
};
// Helper to check if directory exists
function dirExists(dir) {
try {
return fs.statSync(dir).isDirectory();
} catch {
return false;
}
}
// Helper to check if file exists
function fileExists(file) {
try {
return fs.statSync(file).isFile();
} catch {
return false;
}
}
// 1. Check prerendered routes
function checkPrerenderRoutes() {
console.log('\n🔍 Checking prerendered routes...');
const outputDir = dirExists(OUTPUT_DIR) ? OUTPUT_DIR : (dirExists(DIST_DIR) ? DIST_DIR : null);
if (!outputDir) {
diagnostics.problems.push('No build output directory found (.output/public or dist)');
diagnostics.recommendations.push('Run `nuxi generate` or `npm run generate` to build the project');
diagnostics.status = 'failed';
return;
}
diagnostics.details.outputDir = outputDir;
// Check expected routes
const expectedRoutes = [
'index.html',
'blog/index.html',
'fa/index.html',
'fa/blog/index.html'
];
const missingRoutes = [];
const foundRoutes = [];
expectedRoutes.forEach(route => {
const fullPath = path.join(outputDir, route);
if (fileExists(fullPath)) {
foundRoutes.push(route);
} else {
missingRoutes.push(route);
}
});
diagnostics.details.foundRoutes = foundRoutes;
diagnostics.details.missingRoutes = missingRoutes;
if (missingRoutes.length > 0) {
diagnostics.problems.push(`Prerendered routes missing: ${missingRoutes.join(', ')}`);
diagnostics.status = 'failed';
}
console.log(`✅ Found ${foundRoutes.length} routes`);
if (missingRoutes.length > 0) {
console.log(`❌ Missing ${missingRoutes.length} routes: ${missingRoutes.join(', ')}`);
}
}
// 2. Check build assets
function checkBuildAssets() {
console.log('\n🔍 Checking build assets...');
const outputDir = diagnostics.details.outputDir;
if (!outputDir) return;
const nuxtDir = path.join(outputDir, '_nuxt');
if (!dirExists(nuxtDir)) {
diagnostics.problems.push('Build assets missing: /_nuxt/ directory not found');
diagnostics.status = 'failed';
console.log('❌ /_nuxt/ directory not found');
return;
}
// Count JS files
const files = fs.readdirSync(nuxtDir);
const jsFiles = files.filter(f => f.endsWith('.js'));
const cssFiles = files.filter(f => f.endsWith('.css'));
diagnostics.details.buildAssets = {
totalFiles: files.length,
jsFiles: jsFiles.length,
cssFiles: cssFiles.length
};
console.log(`✅ Found ${jsFiles.length} JS files and ${cssFiles.length} CSS files in /_nuxt/`);
// Check for builds/meta directory
const buildsMetaDir = path.join(nuxtDir, 'builds', 'meta');
if (dirExists(buildsMetaDir)) {
const metaFiles = fs.readdirSync(buildsMetaDir);
diagnostics.details.buildAssets.metaFiles = metaFiles.length;
console.log(`✅ Found ${metaFiles.length} meta files`);
}
}
// 3. Check script tags in HTML
function checkScriptTags() {
console.log('\n🔍 Checking script tags in HTML files...');
const outputDir = diagnostics.details.outputDir;
if (!outputDir) return;
const htmlFiles = [
'index.html',
'blog/index.html',
'blog/getting-started-with-nuxt-content/index.html'
];
const relativePathIssues = [];
htmlFiles.forEach(htmlFile => {
const fullPath = path.join(outputDir, htmlFile);
if (!fileExists(fullPath)) return;
const content = fs.readFileSync(fullPath, 'utf-8');
// Check for relative script paths (not starting with /)
const scriptRegex = /<script[^>]+src=["']([^"']+)["']/g;
const linkRegex = /<link[^>]+href=["']([^"']+)["']/g;
let match;
while ((match = scriptRegex.exec(content)) !== null) {
const src = match[1];
if (src.includes('_nuxt') && !src.startsWith('/')) {
relativePathIssues.push({ file: htmlFile, path: src, type: 'script' });
}
}
while ((match = linkRegex.exec(content)) !== null) {
const href = match[1];
if (href.includes('_nuxt') && !href.startsWith('/')) {
relativePathIssues.push({ file: htmlFile, path: href, type: 'link' });
}
}
});
diagnostics.details.relativePathIssues = relativePathIssues;
if (relativePathIssues.length > 0) {
diagnostics.problems.push(`Relative JS/CSS paths detected in ${relativePathIssues.length} locations`);
diagnostics.recommendations.push('Ensure app.baseURL is set to \'/\' in nuxt.config.ts');
diagnostics.status = 'failed';
console.log(`❌ Found ${relativePathIssues.length} relative path issues`);
relativePathIssues.slice(0, 5).forEach(issue => {
console.log(` - ${issue.file}: ${issue.path}`);
});
} else {
console.log('✅ All script/link paths are absolute');
}
}
// 4. Check nuxt.config.ts
function checkNuxtConfig() {
console.log('\n🔍 Checking nuxt.config.ts...');
if (!fileExists(CONFIG_FILE)) {
diagnostics.problems.push('nuxt.config.ts not found');
return;
}
const config = fs.readFileSync(CONFIG_FILE, 'utf-8');
const issues = [];
// Check baseURL
if (!config.includes('baseURL:') || !config.match(/baseURL:\s*['"]\/['"]/)) {
issues.push('app.baseURL should be set to \'/\'');
}
// Check buildAssetsDir
if (!config.includes('buildAssetsDir:') || !config.match(/buildAssetsDir:\s*['"]\/\_nuxt\/['"]/)) {
issues.push('app.buildAssetsDir should be set to \'/_nuxt/\'');
}
// Check cdnURL
if (config.includes('cdnURL:') && !config.match(/cdnURL:\s*['"]\/['"]/)) {
issues.push('app.cdnURL should be set to \'/\' for SSG');
}
// Check prerender routes
if (!config.includes('prerender:')) {
issues.push('Consider adding nitro.prerender.routes configuration');
}
diagnostics.details.configIssues = issues;
if (issues.length > 0) {
issues.forEach(issue => {
diagnostics.problems.push(`Config issue: ${issue}`);
});
console.log(`⚠️ Found ${issues.length} configuration issues`);
issues.forEach(issue => console.log(` - ${issue}`));
} else {
console.log('✅ Configuration looks good');
}
}
// 5. Check for common deployment issues
function checkDeploymentIssues() {
console.log('\n🔍 Checking deployment configuration...');
const outputDir = diagnostics.details.outputDir;
if (!outputDir) return;
// Check if 200.html exists (for SPA fallback)
const fallbackFile = path.join(outputDir, '200.html');
if (fileExists(fallbackFile)) {
console.log('✅ Found 200.html (SPA fallback)');
diagnostics.details.hasSpaFallback = true;
}
// Check if 404.html exists
const notFoundFile = path.join(outputDir, '404.html');
if (fileExists(notFoundFile)) {
console.log('✅ Found 404.html');
diagnostics.details.has404Page = true;
}
// Check vercel.json
if (fileExists('vercel.json')) {
console.log('✅ Found vercel.json');
diagnostics.details.hasVercelConfig = true;
}
}
// Main diagnostic function
function runDiagnostics() {
console.log('🚀 Starting Nuxt SSG Diagnostics...\n');
console.log('=' .repeat(60));
checkPrerenderRoutes();
checkBuildAssets();
checkScriptTags();
checkNuxtConfig();
checkDeploymentIssues();
console.log('\n' + '='.repeat(60));
console.log('\n📊 DIAGNOSTIC SUMMARY\n');
console.log('Status:', diagnostics.status === 'ok' ? '✅ OK' : '❌ FAILED');
console.log('\nProblems found:', diagnostics.problems.length);
if (diagnostics.problems.length > 0) {
console.log('\n🔴 PROBLEMS:');
diagnostics.problems.forEach((problem, i) => {
console.log(`${i + 1}. ${problem}`);
});
}
if (diagnostics.recommendations.length > 0) {
console.log('\n💡 RECOMMENDATIONS:');
diagnostics.recommendations.forEach((rec, i) => {
console.log(`${i + 1}. ${rec}`);
});
}
// Write JSON report
fs.writeFileSync('nuxt-diagnostic-report.json', JSON.stringify(diagnostics, null, 2));
console.log('\n📄 Full report saved to: nuxt-diagnostic-report.json');
console.log('\n' + '='.repeat(60));
// Specific diagnosis for the user's issue
console.log('\n🎯 SPECIFIC DIAGNOSIS FOR YOUR ISSUE:\n');
console.log('You mentioned that JS files return 404 when refreshing /blog pages.');
console.log('This is typically caused by:\n');
console.log('1. ❌ Relative asset paths in HTML (e.g., "_nuxt/file.js" instead of "/_nuxt/file.js")');
console.log('2. ❌ Missing or incorrect baseURL configuration');
console.log('3. ❌ Server not configured to serve static assets from subdirectories\n');
if (diagnostics.details.relativePathIssues && diagnostics.details.relativePathIssues.length > 0) {
console.log('🔴 FOUND THE PROBLEM: Relative paths detected in your HTML!');
console.log(' This means when you\'re on /blog, the browser looks for:');
console.log(' /blog/_nuxt/file.js instead of /_nuxt/file.js\n');
} else {
console.log('✅ Your HTML files use absolute paths correctly.');
console.log(' The issue might be with your deployment server configuration.\n');
}
return diagnostics.status === 'ok' ? 0 : 1;
}
// Run diagnostics
const exitCode = runDiagnostics();
process.exit(exitCode);
+75 -5
View File
@@ -1,12 +1,35 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
srcDir: 'app', srcDir: 'app',
// Enable View Transitions API for smooth page transitions
experimental: {
viewTransition: true
},
// Configure page and layout transitions
app: {
baseURL: '/',
buildAssetsDir: '/_nuxt/',
cdnURL: '/',
pageTransition: {
name: 'page',
mode: 'out-in'
},
layoutTransition: {
name: 'layout',
mode: 'out-in'
}
},
modules: [ modules: [
'@nuxt/content',
'@nuxt/fonts', '@nuxt/fonts',
'@nuxt/ui', '@nuxt/ui',
'@nuxtjs/i18n', '@nuxtjs/i18n',
'@nuxtjs/color-mode', '@nuxtjs/color-mode',
'@nuxt/image' '@nuxt/image',
'@nuxtjs/sitemap'
], ],
css: [ css: [
'~/assets/css/main.css' '~/assets/css/main.css'
@@ -55,9 +78,25 @@ export default defineNuxtConfig({
runtimeConfig: { runtimeConfig: {
public: { public: {
loadPlausible: "", // overrided by env, loadPlausible: "", // overrided by env,
siteUrl: 'https://aliarghyani.vercel.app' // Used for sitemap and RSS generation
}, },
}, },
// Site configuration for sitemap
site: {
url: 'https://aliarghyani.vercel.app'
} as any,
// Sitemap configuration
sitemap: {
gzip: true,
exclude: [],
defaults: {
changefreq: 'monthly',
priority: 0.8
}
} as any,
image: { image: {
quality: 80, quality: 80,
domains: [], domains: [],
@@ -92,9 +131,27 @@ export default defineNuxtConfig({
storageKey: "nuxt-color-mode", storageKey: "nuxt-color-mode",
}, },
//
// Nuxt Content configuration
content: {
// Disable experimental features that require native dependencies
experimental: {
clientDB: false,
cacheContents: false
},
markdown: {
mdc: true,
toc: {
depth: 3,
searchDepth: 3
}
},
documentDriven: false,
respectPathCase: true
},
i18n: { i18n: {
defaultLocale: 'en', defaultLocale: 'en',
strategy: 'prefix_except_default', strategy: 'prefix_except_default',
@@ -113,14 +170,27 @@ export default defineNuxtConfig({
vueI18n: '~/i18n.config.ts' vueI18n: '~/i18n.config.ts'
}, },
// Avoid Windows prerender issues and speed up local builds // Prerender blog routes
nitro: { nitro: {
prerender: { prerender: {
crawlLinks: false, crawlLinks: true,
routes: [], routes: ['/', '/blog', '/fa/blog'],
failOnError: false,
ignore: ['/_vercel/image']
}, },
}, },
// Route rules for caching and optimization
routeRules: {
// Blog routes caching
'/blog': { swr: 3600 },
'/fa/blog': { swr: 3600 },
'/blog/**': { swr: 3600 },
'/fa/blog/**': { swr: 3600 }
},
devtools: { enabled: false }, devtools: { enabled: false },
compatibilityDate: "2024-07-10", compatibilityDate: "2024-07-10",
+3 -4
View File
@@ -13,16 +13,14 @@
"format:write": "prettier --write ." "format:write": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@electric-sql/pglite": "^0.3.11", "@nuxt/content": "^3.8.0",
"@nuxt/fonts": "^0.11.4", "@nuxt/fonts": "^0.11.4",
"@nuxt/image": "^1.11.0", "@nuxt/image": "^1.11.0",
"@nuxt/ui": "^4.0.1", "@nuxt/ui": "^4.0.1",
"@nuxtjs/color-mode": "3.5.2", "@nuxtjs/color-mode": "3.5.2",
"@nuxtjs/i18n": "10.1.1", "@nuxtjs/i18n": "10.1.1",
"@oxc-minify/binding-win32-x64-msvc": "^0.96.0",
"@oxc-parser/binding-win32-x64-msvc": "^0.96.0",
"@oxc-transform/binding-win32-x64-msvc": "^0.96.0",
"@vueuse/core": "13.9.0", "@vueuse/core": "13.9.0",
"better-sqlite3": "^12.4.1",
"embla-carousel": "8.6.0", "embla-carousel": "8.6.0",
"nuxt": "^4.1.3" "nuxt": "^4.1.3"
}, },
@@ -36,6 +34,7 @@
"@iconify-json/ph": "^1.2.2", "@iconify-json/ph": "^1.2.2",
"@iconify-json/twemoji": "^1.2.4", "@iconify-json/twemoji": "^1.2.4",
"@iconify-json/vscode-icons": "^1.2.32", "@iconify-json/vscode-icons": "^1.2.32",
"@nuxtjs/sitemap": "^7.4.7",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/node": "22.18.11", "@types/node": "22.18.11",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
+2625 -974
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
# Default blog cover images directory
# Add your blog cover images here
+12
View File
@@ -0,0 +1,12 @@
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#grad)"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="64" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="middle">
Blog Post
</text>
</svg>

After

Width:  |  Height:  |  Size: 552 B

+28
View File
@@ -0,0 +1,28 @@
export default defineNitroPlugin(async (nitroApp) => {
if (!import.meta.prerender) {
return
}
const routes: string[] = []
nitroApp.hooks.hook('content:file:afterParse' as any, (file: any) => {
// Collect blog post routes for prerendering
if (file._path && !file.draft) {
// English routes: /en/blog/post -> /blog/post (no prefix for default locale)
if (file._path.startsWith('/en/blog/')) {
const routeWithoutPrefix = file._path.replace('/en', '')
routes.push(routeWithoutPrefix)
}
// Persian routes: /fa/blog/post -> /fa/blog/post (keep prefix)
else if (file._path.startsWith('/fa/blog/')) {
routes.push(file._path)
}
}
})
nitroApp.hooks.hook('prerender:generate' as any, async () => {
for (const route of routes) {
console.log(`Prerendering blog route: ${route}`)
}
})
})
+53
View File
@@ -0,0 +1,53 @@
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const siteUrl = config.public.siteUrl || 'https://example.com'
// Detect locale from path
const locale = 'en'
// Fetch published blog posts
const posts = await serverQueryContent(event, `${locale}/blog`)
.where({ draft: { $ne: true } })
.sort({ date: -1 })
.find()
const escapeXml = (str: string) => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
const rssItems = posts
.map((post) => {
const link = `${siteUrl}${post._path}`
const pubDate = new Date(post.date).toUTCString()
return `
<item>
<title>${escapeXml(post.title)}</title>
<link>${escapeXml(link)}</link>
<guid>${escapeXml(link)}</guid>
<pubDate>${pubDate}</pubDate>
<description>${escapeXml(post.description || '')}</description>
</item>`
})
.join('')
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Blog - ${escapeXml(config.public.siteName || 'My Site')}</title>
<link>${siteUrl}/blog</link>
<description>Latest blog posts</description>
<language>${locale}</language>
<atom:link href="${siteUrl}/blog/rss.xml" rel="self" type="application/rss+xml" />
${rssItems}
</channel>
</rss>`
event.node.res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8')
return rss
})
+53
View File
@@ -0,0 +1,53 @@
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const siteUrl = config.public.siteUrl || 'https://example.com'
// Persian locale
const locale = 'fa'
// Fetch published blog posts
const posts = await serverQueryContent(event, `${locale}/blog`)
.where({ draft: { $ne: true } })
.sort({ date: -1 })
.find()
const escapeXml = (str: string) => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
const rssItems = posts
.map((post) => {
const link = `${siteUrl}${post._path}`
const pubDate = new Date(post.date).toUTCString()
return `
<item>
<title>${escapeXml(post.title)}</title>
<link>${escapeXml(link)}</link>
<guid>${escapeXml(link)}</guid>
<pubDate>${pubDate}</pubDate>
<description>${escapeXml(post.description || '')}</description>
</item>`
})
.join('')
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>وبلاگ - ${escapeXml(config.public.siteName || 'سایت من')}</title>
<link>${siteUrl}/fa/blog</link>
<description>آخرین پست‌های وبلاگ</description>
<language>${locale}</language>
<atom:link href="${siteUrl}/fa/blog/rss.xml" rel="self" type="application/rss+xml" />
${rssItems}
</channel>
</rss>`
event.node.res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8')
return rss
})
+40
View File
@@ -0,0 +1,40 @@
# تست SSG بلاگ
## مراحل تست:
### 1. Build کن:
```bash
npm run generate
```
### 2. چک کن فایل‌های HTML ساخته شدن:
```bash
dir .output\public\blog
dir .output\public\fa\blog
```
### 3. محتوای یه فایل رو ببین:
```bash
type .output\public\blog\getting-started-with-nuxt-content\index.html
```
اگه محتوای کامل پست رو توی HTML دیدی = SSG کار می‌کنه ✅
### 4. سرور static رو اجرا کن:
```bash
npx serve .output\public
```
بعد مستقیماً برو روی یه پست بلاگ - باید بدون هیچ loading نشون بده.
---
## چیزایی که باید توی HTML ببینی:
✅ عنوان پست
✅ محتوای کامل
✅ تگ‌ها
✅ تاریخ
✅ Meta tags برای SEO
اگه این‌ها رو دیدی، یعنی SSG درست کار می‌کنه!
+11
View File
@@ -8,5 +8,16 @@
"source": "/stats/api/event", "source": "/stats/api/event",
"destination": "https://plausible.io/api/event" "destination": "https://plausible.io/api/event"
} }
],
"headers": [
{
"source": "/_nuxt/(.*)",
"headers": [
{
"key": "cache-control",
"value": "public, max-age=31536000, immutable"
}
]
}
] ]
} }