mirror of
https://github.com/mmahdium/portfolio.git
synced 2025-12-20 09:23:54 +01:00
implement ssg config for blog posts in project ,
This commit is contained in:
394
.kiro/specs/blog-ssg-optimization/design.md
Normal file
394
.kiro/specs/blog-ssg-optimization/design.md
Normal 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
|
||||
83
.kiro/specs/blog-ssg-optimization/requirements.md
Normal file
83
.kiro/specs/blog-ssg-optimization/requirements.md
Normal file
@@ -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
|
||||
78
.kiro/specs/blog-ssg-optimization/tasks.md
Normal file
78
.kiro/specs/blog-ssg-optimization/tasks.md
Normal file
@@ -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
.kiro/specs/i18n-routing-fixes/design.md
Normal file
595
.kiro/specs/i18n-routing-fixes/design.md
Normal 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.
|
||||
113
.kiro/specs/i18n-routing-fixes/requirements.md
Normal file
113
.kiro/specs/i18n-routing-fixes/requirements.md
Normal file
@@ -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
.kiro/specs/i18n-routing-fixes/tasks.md
Normal file
101
.kiro/specs/i18n-routing-fixes/tasks.md
Normal 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
.kiro/specs/layout-refactoring/design.md
Normal file
226
.kiro/specs/layout-refactoring/design.md
Normal 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)
|
||||
60
.kiro/specs/layout-refactoring/requirements.md
Normal file
60
.kiro/specs/layout-refactoring/requirements.md
Normal file
@@ -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
.kiro/specs/layout-refactoring/tasks.md
Normal file
40
.kiro/specs/layout-refactoring/tasks.md
Normal 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_
|
||||
410
.kiro/specs/page-transitions-ux/design.md
Normal file
410
.kiro/specs/page-transitions-ux/design.md
Normal 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)
|
||||
72
.kiro/specs/page-transitions-ux/requirements.md
Normal file
72
.kiro/specs/page-transitions-ux/requirements.md
Normal file
@@ -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
.kiro/specs/page-transitions-ux/tasks.md
Normal file
97
.kiro/specs/page-transitions-ux/tasks.md
Normal 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_
|
||||
31
README.md
31
README.md
@@ -95,6 +95,21 @@ i18n/ # Internationalization
|
||||
|
||||
## 🚀 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)
|
||||
```bash
|
||||
# Install Vercel CLI
|
||||
@@ -106,16 +121,20 @@ vercel
|
||||
|
||||
Or connect your GitHub repository to Vercel for automatic deployments.
|
||||
|
||||
### Static Hosting
|
||||
```bash
|
||||
# Generate static files
|
||||
pnpm generate
|
||||
### Other Static Hosting Platforms
|
||||
|
||||
# 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
|
||||
- `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
|
||||
|
||||
|
||||
149
SSG-TEST-GUIDE.md
Normal file
149
SSG-TEST-GUIDE.md
Normal 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 لازم نیست! فقط فایلهای استاتیک.
|
||||
14
app/app.vue
14
app/app.vue
@@ -1,23 +1,17 @@
|
||||
<template>
|
||||
<UApp :toaster="{ expand: false }">
|
||||
<NuxtLoadingIndicator color="#6366F1" :height="3" :throttle="100" />
|
||||
<TopNav />
|
||||
<NuxtPage />
|
||||
<FooterCopyright />
|
||||
<!-- <FloatingActions /> -->
|
||||
<NuxtLoadingIndicator color="#6366F1" :height="3" :throttle="100" :duration="2000" />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
<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 { useLocaleSwitching } from '@/composables/useLocaleSwitching'
|
||||
|
||||
const { locale, locales } = useI18n()
|
||||
const portfolio = usePortfolio()
|
||||
const { isLocaleSwitching } = useLocaleSwitching()
|
||||
|
||||
const activeLocale = computed(() => locales.value.find((item) => item.code === locale.value) ?? locales.value[0])
|
||||
const langAttr = computed(() => (activeLocale.value as any)?.language ?? locale.value)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
@import "./transitions.css";
|
||||
|
||||
@source "../../components/**/*.{vue,js,ts}";
|
||||
@source "../../layouts/**/*.vue";
|
||||
|
||||
195
app/assets/css/transitions.css
Normal file
195
app/assets/css/transitions.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,22 @@
|
||||
<!-- Nuxt UI Select-based language picker -->
|
||||
<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]"
|
||||
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="{
|
||||
base: 'rounded-full',
|
||||
value: 'sr-only',
|
||||
trailingIcon: 'text-dimmed group-data-[state=open]:rotate-180 transition-transform duration-200',
|
||||
content: 'min-w-fit'
|
||||
}" aria-label="Language selector">
|
||||
content: 'min-w-fit scale-fade-in'
|
||||
}" :aria-label="t('nav.languageSelector')">
|
||||
|
||||
<!-- Leading icon in trigger (already provided by :icon via selectedIcon) -->
|
||||
<template #leading="{ ui }">
|
||||
<!-- Leading icon in trigger -->
|
||||
<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>
|
||||
<!-- <span>{{ item.label }}</span> -->
|
||||
</template>
|
||||
</USelect>
|
||||
</ClientOnly>
|
||||
@@ -29,7 +28,9 @@
|
||||
import { ref, computed, watch } 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 Item = { label: string; value: LangValue; icon: string }
|
||||
@@ -53,14 +54,40 @@ const selectedIcon = computed<string>(() => items.value.find(i => i.value === mo
|
||||
const { startLocaleSwitching } = useLocaleSwitching()
|
||||
const loading = useLoadingIndicator()
|
||||
|
||||
// On selection change, run visual feedback and update i18n
|
||||
watch(model, (val, oldVal) => {
|
||||
// On selection change, update locale and navigate
|
||||
watch(model, async (val, oldVal) => {
|
||||
if (val === oldVal) return
|
||||
|
||||
// Preserve scroll position
|
||||
const scrollY = window.scrollY
|
||||
|
||||
startLocaleSwitching(600)
|
||||
if (loading) {
|
||||
loading.start()
|
||||
}
|
||||
|
||||
// Update locale
|
||||
await setLocale(val)
|
||||
|
||||
// Get the current route path without locale prefix
|
||||
const currentPath = router.currentRoute.value.path
|
||||
const pathWithoutLocale = currentPath.replace(/^\/(en|fa)/, '')
|
||||
|
||||
// Build new path with new locale
|
||||
const newLocalePrefix = val === 'en' ? '' : `/${val}`
|
||||
const newPath = `${newLocalePrefix}${pathWithoutLocale || '/'}`
|
||||
|
||||
// Navigate to new path
|
||||
if (newPath !== currentPath) {
|
||||
await router.push(newPath)
|
||||
}
|
||||
|
||||
// Restore scroll position after navigation
|
||||
await nextTick()
|
||||
window.scrollTo(0, scrollY)
|
||||
|
||||
if (loading) {
|
||||
setTimeout(() => loading.finish(), 600)
|
||||
}
|
||||
setLocale(val)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -59,7 +59,7 @@ const handleImageError = () => {
|
||||
|
||||
// Convert collection path to route path (remove locale prefix)
|
||||
const getRoutePath = (path: string) => {
|
||||
// Remove locale prefix from path: /en/blog/... -> /blog/...
|
||||
return path.replace(`/${locale.value}`, '')
|
||||
// Remove any locale prefix from path: /en/blog/... or /fa/blog/... -> /blog/...
|
||||
return path.replace(/^\/(en|fa)/, '')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,8 +12,8 @@ const router = useRouter()
|
||||
|
||||
// Convert collection path to route path (remove locale prefix)
|
||||
const getRoutePath = (path: string) => {
|
||||
// Remove locale prefix from path: /en/blog/... -> /blog/...
|
||||
return path.replace(`/${locale.value}`, '')
|
||||
// Remove any locale prefix from path: /en/blog/... or /fa/blog/... -> /blog/...
|
||||
return path.replace(/^\/(en|fa)/, '')
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
@@ -39,9 +39,11 @@ onUnmounted(() => {
|
||||
<!-- 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">
|
||||
<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" />
|
||||
<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">
|
||||
@@ -59,7 +61,8 @@ onUnmounted(() => {
|
||||
<!-- 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">
|
||||
<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') }}
|
||||
@@ -70,7 +73,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<template #trailing>
|
||||
<UIcon name="i-heroicons-arrow-right" class="w-5 h-5" />
|
||||
<UIcon name="i-heroicons-arrow-right"
|
||||
class="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</template>
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
<footer class="py-10">
|
||||
<UContainer>
|
||||
<div class="flex flex-col items-center gap-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<NuxtImg :src="logoSrc" alt="Ali Arghyani logo" width="64" height="64" class="h-12 w-12" format="png"
|
||||
loading="lazy" />
|
||||
<ClientOnly>
|
||||
<NuxtImg :src="logoSrc" alt="Ali Arghyani logo" width="64" height="64" class="h-12 w-12" format="png"
|
||||
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>© {{ currentYear }}, <span class="font-semibold text-gray-900 dark:text-gray-100">AliArghyani</span> -
|
||||
All rights reserved.</p>
|
||||
<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 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'
|
||||
|
||||
@@ -1,94 +1,93 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<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="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">
|
||||
<div class="flex items-center justify-between px-2 py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Home -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('nav.home')">
|
||||
<UButton class="cursor-pointer" :class="[isActive('hero') ? activeClass : inactiveClass]"
|
||||
variant="soft" square icon="i-twemoji-house" :aria-label="t('nav.home')" @click="goTo('hero')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors"
|
||||
:class="[isActive('hero') ? labelActiveClass : labelInactiveClass]" @click="goTo('hero')">
|
||||
{{ t('nav.home') }}
|
||||
</button>
|
||||
</div>
|
||||
<nav class="fixed inset-x-0 top-0 z-50 pointer-events-auto transition-transform duration-300" data-section-header>
|
||||
<div class="mx-auto max-w-6xl px-4 pt-2">
|
||||
<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 transition-all duration-300">
|
||||
<div class="flex items-center justify-between px-2 py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Home -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('nav.home')">
|
||||
<UButton class="cursor-pointer transition-all duration-200"
|
||||
:class="[isActive('hero') ? activeClass : inactiveClass]" variant="soft" square icon="i-twemoji-house"
|
||||
:aria-label="t('nav.home')" @click="goTo('hero')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
|
||||
:class="[isActive('hero') ? labelActiveClass : labelInactiveClass]" @click="goTo('hero')">
|
||||
{{ t('nav.home') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Skills -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.skills')">
|
||||
<UButton class="cursor-pointer" :class="[isActive('skills') ? activeClass : inactiveClass]"
|
||||
variant="soft" square icon="i-twemoji-hammer-and-wrench" :aria-label="t('sections.skills')"
|
||||
@click="goTo('skills')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors"
|
||||
:class="[isActive('skills') ? labelActiveClass : labelInactiveClass]" @click="goTo('skills')">
|
||||
{{ t('sections.skills') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Skills -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.skills')">
|
||||
<UButton class="cursor-pointer transition-all duration-200"
|
||||
:class="[isActive('skills') ? activeClass : inactiveClass]" variant="soft" square
|
||||
icon="i-twemoji-hammer-and-wrench" :aria-label="t('sections.skills')" @click="goTo('skills')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
|
||||
:class="[isActive('skills') ? labelActiveClass : labelInactiveClass]" @click="goTo('skills')">
|
||||
{{ t('sections.skills') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Work -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.work')">
|
||||
<UButton class="cursor-pointer" :class="[isActive('work') ? activeClass : inactiveClass]"
|
||||
variant="soft" square icon="i-twemoji-briefcase" :aria-label="t('sections.work')"
|
||||
@click="goTo('work')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors"
|
||||
:class="[isActive('work') ? labelActiveClass : labelInactiveClass]" @click="goTo('work')">
|
||||
{{ t('sections.work') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Work -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.work')">
|
||||
<UButton class="cursor-pointer transition-all duration-200"
|
||||
:class="[isActive('work') ? activeClass : inactiveClass]" variant="soft" square
|
||||
icon="i-twemoji-briefcase" :aria-label="t('sections.work')" @click="goTo('work')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
|
||||
:class="[isActive('work') ? labelActiveClass : labelInactiveClass]" @click="goTo('work')">
|
||||
{{ t('sections.work') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Projects -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.projects')">
|
||||
<UButton class="cursor-pointer" :class="[isActive('projects') ? activeClass : inactiveClass]"
|
||||
variant="soft" square icon="i-twemoji-rocket" :aria-label="t('sections.projects')"
|
||||
@click="goTo('projects')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors"
|
||||
:class="[isActive('projects') ? labelActiveClass : labelInactiveClass]" @click="goTo('projects')">
|
||||
{{ t('sections.projects') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Projects -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.projects')">
|
||||
<UButton class="cursor-pointer transition-all duration-200"
|
||||
:class="[isActive('projects') ? activeClass : inactiveClass]" variant="soft" square
|
||||
icon="i-twemoji-rocket" :aria-label="t('sections.projects')" @click="goTo('projects')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
|
||||
:class="[isActive('projects') ? labelActiveClass : labelInactiveClass]" @click="goTo('projects')">
|
||||
{{ t('sections.projects') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Blog -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.blog')">
|
||||
<NuxtLink :to="localePath('/blog')">
|
||||
<UButton class="cursor-pointer" :class="[isBlogActive ? activeClass : inactiveClass]" variant="soft"
|
||||
square icon="i-twemoji-memo" :aria-label="t('sections.blog')" />
|
||||
</NuxtLink>
|
||||
</UTooltip>
|
||||
<!-- Blog -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.blog')">
|
||||
<NuxtLink :to="localePath('/blog')">
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors"
|
||||
:class="[isBlogActive ? labelActiveClass : labelInactiveClass]">
|
||||
{{ t('sections.blog') }}
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</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 class="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<ThemeCustomizer />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<ThemeCustomizer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ThemeCustomizer from '@/components/common/ThemeCustomizer.vue'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
import { useSectionObserver, type SectionId } from '@/composables/useSectionObserver'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -106,21 +105,80 @@ type Target = typeof sectionIds[number]
|
||||
const isHome = computed(() => route.path === localePath('/'))
|
||||
const isBlogActive = computed(() => route.path.includes('/blog'))
|
||||
|
||||
const { activeSection, scrollToSection } = useSectionObserver({
|
||||
ids: [...sectionIds] as SectionId[],
|
||||
headerSelector: 'nav[data-section-header]',
|
||||
offset: 80,
|
||||
enabled: isHome
|
||||
// Active section tracking (client-side only)
|
||||
const activeSection = ref<Target | null>(null)
|
||||
const isMounted = ref(false)
|
||||
|
||||
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) {
|
||||
const homePath = localePath('/')
|
||||
if (route.path !== homePath) {
|
||||
await router.push(homePath)
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => scrollToSection(id))
|
||||
// Wait for next frame to ensure DOM is ready
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
requestAnimationFrame(() => scrollToSection(id))
|
||||
} else {
|
||||
scrollToSection(id)
|
||||
}
|
||||
} else {
|
||||
scrollToSection(id)
|
||||
}
|
||||
|
||||
@@ -50,41 +50,151 @@ const languageLabel = computed(() => {
|
||||
})
|
||||
</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: space-between;
|
||||
padding: 1rem 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: 3rem;
|
||||
}
|
||||
|
||||
.code-filename {
|
||||
font-size: 0.9375rem;
|
||||
color: #e2e8f0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-language {
|
||||
font-size: 0.8125rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.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 style="position: relative; margin: 1.5rem 0;" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
|
||||
<div class="code-block-wrapper" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
|
||||
<!-- Header with filename and language -->
|
||||
<div v-if="filename || language"
|
||||
style="display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 1rem; background-color: #1f2937; border-bottom: 1px solid #374151; border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||
<span v-if="filename" style="font-size: 0.875rem; color: #d1d5db; font-family: monospace;">
|
||||
<div v-if="filename || language" class="code-block-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<span v-if="filename" class="code-filename">
|
||||
{{ filename }}
|
||||
</span>
|
||||
<span v-if="language && !filename"
|
||||
style="font-size: 0.75rem; color: #9ca3af; text-transform: uppercase; font-weight: 600;">
|
||||
<span v-if="language && !filename" class="code-language">
|
||||
{{ languageLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code block -->
|
||||
<div style="position: relative;">
|
||||
<pre :class="props.class" style="overflow-x: auto; padding: 1rem; font-size: 0.875rem; margin: 0;"><slot /></pre>
|
||||
<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')" :style="{
|
||||
position: 'absolute',
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '0.375rem',
|
||||
backgroundColor: 'rgba(55, 65, 81, 0.5)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
opacity: isHovered || copied ? '1' : '0'
|
||||
}" @click="copyCode">
|
||||
<UIcon :name="copied ? 'i-heroicons-check' : 'i-heroicons-clipboard-document'"
|
||||
style="width: 1rem; height: 1rem; color: rgb(209, 213, 219);" />
|
||||
<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>
|
||||
|
||||
80
app/composables/useViewTransition.ts
Normal file
80
app/composables/useViewTransition.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,25 @@
|
||||
<template>
|
||||
<div class="layout-default">
|
||||
<!-- Default layout wrapper -->
|
||||
<TopNav client:only />
|
||||
<slot />
|
||||
<FooterCopyright />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TopNav from '@/components/common/TopNav.vue'
|
||||
import FooterCopyright from '@/components/common/FooterCopyright.vue'
|
||||
|
||||
/**
|
||||
* Default Layout
|
||||
*
|
||||
* This is a minimal example layout demonstrating Nuxt's layout system.
|
||||
* To use this layout in a page, add: definePageMeta({ layout: 'default' })
|
||||
*
|
||||
* Learn more: https://nuxt.com/docs/guide/directory-structure/layouts
|
||||
* Main layout for the application including TopNav and Footer.
|
||||
* This layout is used by default for all pages unless specified otherwise.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-default {
|
||||
/* Add default layout styles here */
|
||||
/* Layout wrapper */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,7 @@ const nextPost = computed(() => {
|
||||
})
|
||||
|
||||
// SEO meta tags
|
||||
const siteUrl = 'https://aliarghyani.com' // TODO: Move to runtime config
|
||||
const siteUrl = 'https://aliarghyani.vercel.app' // TODO: Move to runtime config
|
||||
|
||||
// Custom meta tags
|
||||
if (post.value) {
|
||||
@@ -105,7 +105,7 @@ if (post.value) {
|
||||
|
||||
<template>
|
||||
<UContainer>
|
||||
<div v-if="post" class="py-8">
|
||||
<div v-if="post" class="pt-24 pb-12">
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<UBreadcrumb :links="[
|
||||
{ label: t('nav.home'), to: localePath('/') },
|
||||
@@ -128,7 +128,8 @@ if (post.value) {
|
||||
<BlogPost :post="post" />
|
||||
|
||||
<!-- Content Renderer -->
|
||||
<article :dir="locale === 'fa' ? 'rtl' : 'ltr'" class="prose prose-lg dark:prose-invert max-w-none mt-8">
|
||||
<article :dir="locale === 'fa' ? 'rtl' : 'ltr'" class="prose prose-lg dark:prose-invert max-w-none mt-8"
|
||||
suppressHydrationWarning>
|
||||
<ContentRenderer v-if="(post as any).body" :value="(post as any).body" />
|
||||
</article>
|
||||
|
||||
@@ -152,4 +153,49 @@ article[dir="rtl"] :deep(code) {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Better spacing for prose elements */
|
||||
article :deep(h1) {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
article :deep(h2) {
|
||||
margin-top: 2.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
article :deep(h3) {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
article :deep(p) {
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
article :deep(ul),
|
||||
article :deep(ol) {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
article :deep(li) {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
article :deep(pre) {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
article :deep(blockquote) {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-left: 1.5rem;
|
||||
border-left: 4px solid rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section class="py-10">
|
||||
<section class="pt-24 pb-10">
|
||||
<UContainer>
|
||||
<div class="mb-8 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
"skills": "Skills",
|
||||
"blog": "Blog",
|
||||
"contact": "Contact",
|
||||
"theme": "Theme"
|
||||
"theme": "Theme",
|
||||
"languageSelector": "Language selector"
|
||||
},
|
||||
"blog": {
|
||||
"title": "Blog",
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
"skills": "مهارتها",
|
||||
"blog": "وبلاگ",
|
||||
"contact": "ارتباط",
|
||||
"theme": "تم"
|
||||
"theme": "تم",
|
||||
"languageSelector": "انتخاب زبان"
|
||||
},
|
||||
"blog": {
|
||||
"title": "وبلاگ",
|
||||
|
||||
26
nuxt-diagnostic-report.json
Normal file
26
nuxt-diagnostic-report.json
Normal 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
nuxt-ssg-diagnostic.cjs
Normal file
313
nuxt-ssg-diagnostic.cjs
Normal 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);
|
||||
@@ -1,13 +1,35 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
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: [
|
||||
'@nuxt/content',
|
||||
'@nuxt/fonts',
|
||||
'@nuxt/ui',
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxtjs/color-mode',
|
||||
'@nuxt/image'
|
||||
'@nuxt/image',
|
||||
'@nuxtjs/sitemap'
|
||||
],
|
||||
css: [
|
||||
'~/assets/css/main.css'
|
||||
@@ -56,9 +78,25 @@ export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
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: {
|
||||
quality: 80,
|
||||
domains: [],
|
||||
@@ -106,7 +144,11 @@ export default defineNuxtConfig({
|
||||
// Document-driven mode disabled (we use custom pages)
|
||||
documentDriven: false,
|
||||
// Respect path case
|
||||
respectPathCase: true
|
||||
respectPathCase: true,
|
||||
// Experimental: Improve hydration
|
||||
experimental: {
|
||||
clientDB: true
|
||||
}
|
||||
} as any,
|
||||
|
||||
|
||||
@@ -132,7 +174,7 @@ export default defineNuxtConfig({
|
||||
nitro: {
|
||||
prerender: {
|
||||
crawlLinks: true,
|
||||
routes: ['/blog', '/fa/blog'],
|
||||
routes: ['/', '/blog', '/fa/blog'],
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@electric-sql/pglite": "^0.3.11",
|
||||
"@nuxt/content": "^3.8.0",
|
||||
"@nuxt/fonts": "^0.11.4",
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@nuxt/ui": "^4.0.1",
|
||||
@@ -23,6 +24,7 @@
|
||||
"@oxc-parser/binding-win32-x64-msvc": "^0.96.0",
|
||||
"@oxc-transform/binding-win32-x64-msvc": "^0.96.0",
|
||||
"@vueuse/core": "13.9.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"embla-carousel": "8.6.0",
|
||||
"nuxt": "^4.1.3"
|
||||
},
|
||||
@@ -36,6 +38,7 @@
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
"@iconify-json/twemoji": "^1.2.4",
|
||||
"@iconify-json/vscode-icons": "^1.2.32",
|
||||
"@nuxtjs/sitemap": "^7.4.7",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/node": "22.18.11",
|
||||
"autoprefixer": "^10.4.21",
|
||||
|
||||
2056
pnpm-lock.yaml
generated
2056
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
37
server/plugins/prerender.ts
Normal file
37
server/plugins/prerender.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('prerender:routes' as any, async (ctx: any) => {
|
||||
try {
|
||||
// Import serverQueryContent dynamically
|
||||
const { serverQueryContent } = await import('#content/server' as any)
|
||||
|
||||
// Fetch all blog posts from both English and Persian locales
|
||||
const locales = ['en', 'fa']
|
||||
const allPosts: any[] = []
|
||||
|
||||
for (const locale of locales) {
|
||||
const posts = await serverQueryContent(nitroApp as any, `${locale}/blog`)
|
||||
.where({ draft: { $ne: true } })
|
||||
.find()
|
||||
|
||||
allPosts.push(...posts)
|
||||
}
|
||||
|
||||
if (allPosts.length === 0) {
|
||||
console.log('No blog posts found for prerendering')
|
||||
return
|
||||
}
|
||||
|
||||
// Add routes for each published post
|
||||
for (const post of allPosts) {
|
||||
if (post._path) {
|
||||
ctx.routes.add(post._path)
|
||||
console.log(`Added route for prerendering: ${post._path}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Total blog routes added for prerendering: ${allPosts.length}`)
|
||||
} catch (error) {
|
||||
console.error('Error generating prerender routes:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
40
test-ssg.md
Normal file
40
test-ssg.md
Normal 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 درست کار میکنه!
|
||||
@@ -8,5 +8,13 @@
|
||||
"source": "/stats/api/event",
|
||||
"destination": "https://plausible.io/api/event"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/_nuxt/.+",
|
||||
"headers": {
|
||||
"cache-control": "public, max-age=31536000, immutable"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user