add blog to project

This commit is contained in:
mahdiarghyani
2025-11-09 13:56:03 +03:30
parent 24a91baaf5
commit c7e5eb0713
26 changed files with 3310 additions and 11 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
.nuxt .nuxt
.nitro .nitro
.cache .cache
.data
dist dist
# Node dependencies # Node dependencies

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,241 @@
# Requirements Document
## Introduction
This document specifies the requirements for implementing a fully-featured blog system in the Nuxt 4 portfolio application using Nuxt Content v3. The blog system will support bilingual content (English and Persian with RTL), markdown-based content management, SEO optimization, and seamless integration with the existing portfolio design system.
## Glossary
- **Blog System**: The complete blogging functionality including content management, rendering, listing, and navigation
- **Nuxt Content**: The official Nuxt module (@nuxt/content) for file-based content management with markdown support
- **Content Directory**: The file system location where markdown blog posts are stored (content/ folder)
- **Blog Post**: A single article written in markdown format with frontmatter metadata
- **Frontmatter**: YAML metadata at the top of markdown files containing post information (title, date, tags, etc.)
- **Blog Listing Page**: The main blog index page displaying all published posts
- **Blog Detail Page**: Individual post page rendering the full markdown content
- **Content Query**: Nuxt Content's API for fetching and filtering markdown content
- **MDC Syntax**: Markdown Components syntax for embedding Vue components in markdown
- **SEO Metadata**: Meta tags, Open Graph, and structured data for search engine optimization
- **Reading Time**: Calculated estimate of time required to read a blog post
- **Tag System**: Categorization mechanism using tags/labels for blog posts
- **Draft Mode**: Unpublished posts that are hidden from production but visible in development
## Requirements
### Requirement 1: Nuxt Content Module Integration
**User Story:** As a developer, I want to integrate Nuxt Content v3 into the existing Nuxt 4 application, so that I can manage blog content using markdown files.
#### Acceptance Criteria
1. WHEN the developer installs the @nuxt/content package, THE Blog System SHALL use version 3.x compatible with Nuxt 4
2. WHEN the nuxt.config.ts is updated, THE Blog System SHALL register @nuxt/content in the modules array before other content-dependent modules
3. THE Blog System SHALL create a content/ directory in the project root for storing markdown files
4. THE Blog System SHALL configure Nuxt Content with Shiki syntax highlighter for code blocks
5. THE Blog System SHALL enable markdown.mdc option to support Vue component embedding in markdown
6. WHEN the development server starts, THE Blog System SHALL successfully load and parse all markdown files with hot-reload support
### Requirement 2: Content Directory Structure
**User Story:** As a content creator, I want a well-organized content directory structure, so that I can easily manage bilingual blog posts.
#### Acceptance Criteria
1. THE Blog System SHALL create locale-based subdirectories (content/en/blog/ and content/fa/blog/)
2. WHEN queryContent() is called with a locale parameter, THE Blog System SHALL fetch content from the corresponding locale directory
3. THE Blog System SHALL support nested directories within blog folders for content organization (e.g., content/en/blog/tutorials/)
4. THE Blog System SHALL recognize markdown files with .md extension as valid blog posts
5. WHERE a blog post exists in one language but not another, THE Blog System SHALL display a fallback message with a link to the available language version
6. THE Blog System SHALL use the file name (slug) as the URL path segment for blog posts
### Requirement 3: Blog Post Frontmatter Schema
**User Story:** As a content creator, I want a standardized frontmatter schema for blog posts, so that all posts have consistent metadata.
#### Acceptance Criteria
1. THE Blog System SHALL require the following frontmatter fields: title, description, date, and tags
2. THE Blog System SHALL support optional frontmatter fields: image, author, draft, updatedAt, and head (for custom SEO)
3. THE Blog System SHALL define a TypeScript interface extending ParsedContent for type-safe frontmatter access
4. THE Blog System SHALL parse date field as ISO 8601 date string (YYYY-MM-DD or full ISO format)
5. THE Blog System SHALL accept tags as an array of strings for categorization
6. WHERE draft is set to true, THE Blog System SHALL exclude the post from queryContent results in production using where({ draft: { $ne: true } })
7. THE Blog System SHALL use the image field for Open Graph and Twitter Card meta tags
### Requirement 4: Blog Listing Page Implementation
**User Story:** As a visitor, I want to see a list of all published blog posts, so that I can browse available content.
#### Acceptance Criteria
1. WHEN a visitor navigates to /blog or /fa/blog, THE Blog System SHALL use queryContent() to fetch all published posts for the current locale path
2. THE Blog System SHALL sort blog posts by date field in descending order using .sort({ date: -1 })
3. THE Blog System SHALL display post title, description, formatted date, reading time estimate, and tags for each post card
4. WHEN a visitor clicks on a blog post card, THE Blog System SHALL navigate to the localized post detail page using the _path property
5. WHERE no published posts exist for a locale, THE Blog System SHALL display an empty state message with i18n translation
6. THE Blog System SHALL filter out draft posts using .where({ draft: { $ne: true } }) in production environment
7. THE Blog System SHALL calculate reading time from the body.children word count assuming 200 words per minute
8. THE Blog System SHALL use .only() to fetch only required fields (title, description, date, tags, _path, image) for performance
### Requirement 5: Blog Detail Page Implementation
**User Story:** As a visitor, I want to read the full content of a blog post, so that I can consume the article.
#### Acceptance Criteria
1. WHEN a visitor navigates to /blog/[slug] or /fa/blog/[slug], THE Blog System SHALL use ContentDoc component or queryContent().where({ _path: path }).findOne() to fetch the post
2. THE Blog System SHALL render markdown using ContentRenderer component with GitHub Flavored Markdown support
3. THE Blog System SHALL apply Shiki syntax highlighting to code blocks with theme matching the site's color mode
4. THE Blog System SHALL render post metadata (title, formatted date, reading time, tags) in a header section using Nuxt UI components
5. WHERE the requested slug does not exist, THE Blog System SHALL throw a 404 error using createError({ statusCode: 404 })
6. THE Blog System SHALL support MDC syntax (::component-name) for embedding Vue components within markdown
7. THE Blog System SHALL apply Prose components styling from Nuxt UI for consistent typography (ProseH1, ProseP, ProseCode, etc.)
8. THE Blog System SHALL auto-generate anchor links for all headings for easy section sharing
### Requirement 6: SEO and Meta Tags
**User Story:** As a content creator, I want proper SEO metadata for blog posts, so that they rank well in search engines.
#### Acceptance Criteria
1. THE Blog System SHALL use useContentHead() composable to auto-generate meta tags from frontmatter
2. THE Blog System SHALL use useSeoMeta() to set title in format "[Post Title] | Blog | [Site Name]"
3. THE Blog System SHALL generate Open Graph tags (og:title, og:description, og:image, og:type, og:url) from post frontmatter
4. WHERE an image field is specified in frontmatter, THE Blog System SHALL use it for og:image and twitter:image, otherwise use a default blog cover image
5. THE Blog System SHALL set og:type to "article" and include article:published_time and article:tag properties
6. THE Blog System SHALL generate Twitter Card meta tags with card type "summary_large_image"
7. THE Blog System SHALL allow custom head overrides via the head field in frontmatter for advanced SEO control
8. THE Blog System SHALL generate JSON-LD structured data for BlogPosting schema including author, datePublished, and headline
### Requirement 7: Tag Filtering System
**User Story:** As a visitor, I want to filter blog posts by tags, so that I can find content on specific topics.
#### Acceptance Criteria
1. THE Blog System SHALL extract all unique tags from published posts using a computed property that aggregates tags arrays
2. WHEN a visitor clicks on a tag, THE Blog System SHALL filter posts using queryContent().where({ tags: { $contains: selectedTag } })
3. THE Blog System SHALL update the URL query parameter (?tag=value) using useRoute() and navigateTo() when a tag is selected
4. WHEN a visitor clears the tag filter, THE Blog System SHALL remove the query parameter and display all posts
5. THE Blog System SHALL highlight the active tag using Nuxt UI's UBadge or UButton component with active state styling
6. THE Blog System SHALL read the tag query parameter on page load to maintain filter state on navigation or refresh
### Requirement 8: Responsive Design and Accessibility
**User Story:** As a visitor using any device, I want the blog to be fully responsive and accessible, so that I can read content comfortably.
#### Acceptance Criteria
1. THE Blog System SHALL render blog listing and detail pages responsively across mobile, tablet, and desktop viewports
2. THE Blog System SHALL maintain readability with appropriate font sizes and line heights for body text
3. THE Blog System SHALL ensure sufficient color contrast ratios for text and backgrounds (WCAG AA compliance)
4. THE Blog System SHALL support keyboard navigation for all interactive elements
5. THE Blog System SHALL provide appropriate ARIA labels and semantic HTML for screen readers
6. WHERE images are used in blog posts, THE Blog System SHALL require alt text for accessibility
### Requirement 9: RTL Support for Persian Content
**User Story:** As a Persian-speaking visitor, I want blog content to display correctly in RTL layout, so that I can read naturally.
#### Acceptance Criteria
1. WHEN a visitor views Persian blog content, THE Blog System SHALL apply RTL text direction to all content
2. THE Blog System SHALL mirror layout elements appropriately for RTL (navigation, spacing, alignment)
3. THE Blog System SHALL maintain LTR direction for code blocks and technical content within RTL posts
4. THE Blog System SHALL handle mixed LTR/RTL content gracefully (e.g., English words in Persian text)
5. THE Blog System SHALL apply RTL-appropriate typography and spacing rules
### Requirement 10: Performance Optimization
**User Story:** As a visitor, I want blog pages to load quickly, so that I have a smooth browsing experience.
#### Acceptance Criteria
1. THE Blog System SHALL use NuxtImg component for all images in markdown to enable automatic optimization
2. THE Blog System SHALL configure @nuxt/image to generate responsive srcsets and modern formats (webp, avif)
3. THE Blog System SHALL use .only() and .without() query modifiers to fetch minimal data for listing pages
4. THE Blog System SHALL leverage Nuxt Content's built-in caching for content queries in production
5. THE Blog System SHALL prerender all blog routes during build using nitro.prerender.routes configuration
6. THE Blog System SHALL lazy-load blog components using defineAsyncComponent where appropriate
7. THE Blog System SHALL achieve a Lighthouse performance score of 90+ for blog pages
### Requirement 11: Development Experience
**User Story:** As a developer, I want a smooth development experience when working with blog content, so that I can iterate quickly.
#### Acceptance Criteria
1. WHEN a markdown file is modified, THE Blog System SHALL use Nuxt Content's HMR to hot-reload content without full page refresh
2. THE Blog System SHALL include draft posts in queryContent results during development (process.dev check)
3. WHERE a markdown parsing error occurs, THE Blog System SHALL display the error overlay with file path and line number
4. THE Blog System SHALL define TypeScript interfaces for BlogPost extending ParsedContent for type-safe queries
5. THE Blog System SHALL use Nuxt Content's built-in content:list server endpoint for debugging available content
6. THE Blog System SHALL provide helpful console warnings when required frontmatter fields are missing
### Requirement 12: Table of Contents
**User Story:** As a visitor reading a long blog post, I want to see a table of contents, so that I can quickly navigate to specific sections.
#### Acceptance Criteria
1. THE Blog System SHALL extract table of contents from the body.toc property provided by Nuxt Content
2. WHERE a blog post has 3 or more headings, THE Blog System SHALL display a table of contents sidebar on desktop viewports
3. THE Blog System SHALL render TOC links using the heading id and text from body.toc.links array
4. WHEN a visitor clicks a TOC link, THE Blog System SHALL smooth-scroll to the corresponding heading
5. THE Blog System SHALL highlight the active section in TOC based on scroll position using IntersectionObserver
6. THE Blog System SHALL hide the TOC on mobile viewports and show it as a collapsible section instead
7. THE Blog System SHALL support nested heading levels (h2, h3) in the TOC structure
### Requirement 13: Search Functionality
**User Story:** As a visitor, I want to search through blog posts, so that I can quickly find content on specific topics.
#### Acceptance Criteria
1. THE Blog System SHALL provide a search input field on the blog listing page using UInput component
2. WHEN a visitor types in the search field, THE Blog System SHALL filter posts using queryContent().where({ $or: [{ title: { $icontains: query } }, { description: { $icontains: query } }] })
3. THE Blog System SHALL debounce search input by 300ms to avoid excessive queries
4. THE Blog System SHALL display search results count and clear button when search is active
5. THE Blog System SHALL highlight search terms in the results using text highlighting
6. WHERE no results match the search query, THE Blog System SHALL display a "No posts found" message with suggestions
7. THE Blog System SHALL combine search with tag filtering when both are active
### Requirement 14: RSS Feed Generation
**User Story:** As a visitor, I want to subscribe to the blog via RSS, so that I can receive updates on new posts.
#### Acceptance Criteria
1. THE Blog System SHALL generate an RSS feed at /blog/rss.xml for English posts
2. THE Blog System SHALL generate an RSS feed at /fa/blog/rss.xml for Persian posts
3. THE Blog System SHALL use a Nitro server route to dynamically generate RSS XML from queryContent results
4. THE Blog System SHALL include post title, description, link, pubDate, and guid in each RSS item
5. THE Blog System SHALL set proper Content-Type header (application/rss+xml) for RSS endpoints
6. THE Blog System SHALL include channel metadata (title, description, link, language) in the RSS feed
7. THE Blog System SHALL add a link to the RSS feed in the blog listing page header for discoverability
### Requirement 15: Code Block Enhancements
**User Story:** As a visitor reading technical blog posts, I want enhanced code blocks with copy functionality, so that I can easily use code examples.
#### Acceptance Criteria
1. THE Blog System SHALL display a "Copy" button on all code blocks using a custom ProseCode component
2. WHEN a visitor clicks the copy button, THE Blog System SHALL copy the code to clipboard and show a success feedback
3. THE Blog System SHALL display the programming language label on code blocks when specified in markdown
4. THE Blog System SHALL support line highlighting using Nuxt Content's code highlighting syntax (```js{1,3-5})
5. THE Blog System SHALL apply syntax highlighting theme that matches the current color mode (light/dark)
6. THE Blog System SHALL support filename display for code blocks using custom metadata (```js [filename.js])
### Requirement 16: Navigation and Breadcrumbs
**User Story:** As a visitor, I want clear navigation between blog pages, so that I can easily move around the blog section.
#### Acceptance Criteria
1. THE Blog System SHALL display breadcrumb navigation using UBreadcrumb component on blog detail pages showing Home > Blog > [Post Title]
2. THE Blog System SHALL provide a "Back to Blog" link using localePath() to maintain locale context
3. THE Blog System SHALL use useRoute() to detect blog routes and highlight the blog section in TopNav component
4. WHERE previous/next posts exist chronologically, THE Blog System SHALL query adjacent posts using .sort() and .limit() and display navigation links
5. THE Blog System SHALL use localePath() helper from @nuxtjs/i18n for all blog navigation links to maintain locale context
6. THE Blog System SHALL implement keyboard navigation (arrow keys) for previous/next post navigation

View File

@@ -0,0 +1,315 @@
# Implementation Plan
This implementation plan breaks down the blog system development into discrete, actionable coding tasks. Each task builds incrementally on previous work, with all code integrated and functional at each step.
## Task List
- [x] 1. Install and configure Nuxt Content module
- Install @nuxt/content package via pnpm
- Add @nuxt/content to modules array in nuxt.config.ts (before other modules)
- Configure content options: highlight themes (github-light/github-dark), markdown.mdc: true, toc depth
- Add content-specific route rules for caching (/blog, /fa/blog with swr: 3600)
- Verify installation by starting dev server and checking for content module initialization
- _Requirements: 1.1, 1.2, 1.5, 1.6_
- [ ] 2. Create content directory structure and sample posts
- Create content/en/blog/ and content/fa/blog/ directories
- Create TypeScript interface for BlogPost extending ParsedContent in app/types/blog.ts
- Write 2 sample English blog posts with complete frontmatter (title, description, date, tags, image)
- Write 2 sample Persian blog posts with RTL content
- Include code blocks, headings, lists, and images in sample posts for testing
- Create one draft post to test draft filtering
- _Requirements: 2.1, 2.3, 2.4, 3.1, 3.2, 3.5, 3.6_
- [ ] 3. Implement useBlog composable with utility functions
- Create app/composables/useBlog.ts file
- Implement calculateReadingTime function (200 words/min from body.children)
- Implement formatDate function using Intl.DateTimeFormat with locale support
- Implement extractUniqueTags function to aggregate tags from posts array
- Implement getBlogPath function returning locale-aware path
- Implement filterPostsBySearch function for title/description/tags filtering
- Implement filterPostsByTag function
- Export all functions with proper TypeScript types
- _Requirements: 4.7, 11.6_
- [x] 4. Update i18n translation files with blog keys
- Add blog section to i18n/locales/en.json with all required keys (title, explore, empty, readMore, readingTime, publishedOn, backToBlog, previousPost, nextPost, tableOfContents, searchPlaceholder, filterByTag, allPosts, noResults, copyCode, codeCopied, subscribe)
- Add corresponding Persian translations to i18n/locales/fa.json
- Verify translations are loaded by checking in dev tools
- _Requirements: 4.5, 8.1_
- [x] 5. Implement blog listing page
- [x] 5.1 Update app/pages/blog/index.vue with content fetching
- Replace placeholder content with queryContent implementation
- Use useAsyncData to fetch posts for current locale with .where({ draft: { $ne: true } })
- Apply .sort({ date: -1 }) and .only() for required fields
- Implement computed property for extracting unique tags using useBlog composable
- Add reactive refs for searchQuery and selectedTag
- Implement computed filteredPosts using filterPostsBySearch and filterPostsByTag
- _Requirements: 4.1, 4.2, 4.6, 4.8, 7.1_
- [x] 5.2 Create BlogCard component
- Create app/components/blog/BlogCard.vue
- Accept post prop with BlogPost type
- Use UCard as base component with hover effects
- Display NuxtImg for cover image with lazy loading and fallback
- Display title, description, formatted date, reading time, and tags
- Use UBadge for tags display
- Use localePath for navigation link
- Apply responsive styling
- _Requirements: 4.3, 10.1, 10.2_
- [x] 5.3 Create BlogSearch component
- Create app/components/blog/BlogSearch.vue
- Accept modelValue prop and emit update:modelValue
- Use UInput with search icon and clear button
- Implement debounce using useDebounceFn from VueUse (300ms)
- Add i18n placeholder text
- _Requirements: 13.2, 13.3_
- [x] 5.4 Create BlogTagFilter component
- Create app/components/blog/BlogTagFilter.vue
- Accept tags array and modelValue props
- Display tags as UButton or UBadge with click handlers
- Highlight active tag with primary color
- Add "All posts" option to clear filter
- Update URL query parameter using useRoute and navigateTo
- Read query parameter on mount to restore filter state
- Apply horizontal scroll on mobile
- _Requirements: 7.2, 7.3, 7.4, 7.5, 7.6_
- [x] 5.5 Create BlogEmpty component and integrate all components
- Create app/components/blog/BlogEmpty.vue with empty state message
- Import and use BlogSearch, BlogTagFilter, BlogCard, BlogEmpty in index.vue
- Implement grid layout for blog cards (responsive: 1 col mobile, 2 col tablet, 3 col desktop)
- Add page header with title and description using i18n
- Test search, filter, and empty state scenarios
- _Requirements: 4.4, 4.5, 13.4, 13.6_
- [x] 6. Implement blog detail page
- [x] 6.1 Update app/pages/blog/[...slug].vue with content fetching
- Replace placeholder content with queryContent().findOne() implementation
- Use useAsyncData with slug-based key
- Fetch post using _path matching for current locale
- Throw createError({ statusCode: 404 }) if post not found
- Fetch adjacent posts for prev/next navigation using separate query
- Calculate current post index in sorted posts array
- _Requirements: 5.1, 5.5, 16.4_
- [x] 6.2 Implement SEO meta tags and structured data
- Use useContentHead(post) for automatic meta generation
- Use useSeoMeta for custom title, og tags, twitter cards
- Set og:type to "article" with article:published_time and article:tag
- Use post.image or default cover image for og:image
- Add JSON-LD structured data using useHead with BlogPosting schema
- Include author, datePublished, headline in structured data
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.8_
- [ ] 6.3 Create BlogPost metadata component
- Create app/components/blog/BlogPost.vue
- Accept post prop with BlogPost type
- Display post title as h1
- Display formatted date using formatDate from useBlog
- Display reading time using calculateReadingTime from useBlog
- Display author if available
- Display tags as UBadge components
- Display cover image using NuxtImg if available
- Use semantic HTML (article, header, time elements)
- _Requirements: 5.4_
- [ ] 6.4 Implement ContentRenderer with Prose styling
- Use ContentRenderer component to render post.body
- Wrap in article element with proper semantic structure
- Apply dir attribute based on locale (rtl for fa, ltr for en)
- Add CSS to force LTR for code blocks in RTL context
- Verify Shiki syntax highlighting is working
- Test with sample posts containing various markdown elements
- _Requirements: 5.2, 5.3, 5.7, 9.1, 9.2, 9.3_
- [ ] 6.5 Create BlogTableOfContents component
- Create app/components/blog/BlogTableOfContents.vue
- Accept toc prop from post.body.toc
- Render nested heading structure from toc.links
- Implement smooth scroll to heading on link click
- Use IntersectionObserver to track active section
- Highlight active section in TOC
- Make sticky on desktop (position: sticky)
- Make collapsible using UAccordion on mobile
- Only show if post has 3+ headings
- _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7_
- [ ] 6.6 Create BlogNavigation component
- Create app/components/blog/BlogNavigation.vue
- Accept prev and next props (BlogPost | null)
- Display previous post link with title and arrow icon
- Display next post link with title and arrow icon
- Use UButton with icon slots
- Use localePath for navigation links
- Implement keyboard navigation (@keydown for arrow keys)
- Apply flexbox layout with space-between
- _Requirements: 16.4, 16.5, 16.6_
- [ ] 6.7 Create breadcrumb navigation and integrate all components
- Use UBreadcrumb component with links array (Home > Blog > Post Title)
- Use localePath for breadcrumb links
- Import and use BlogPost, ContentRenderer, BlogTableOfContents, BlogNavigation
- Add "Back to Blog" link using localePath
- Implement responsive layout (TOC sidebar on desktop, inline on mobile)
- Test with both English and Persian posts
- Test prev/next navigation
- _Requirements: 16.1, 16.2, 16.3, 16.5_
- [ ] 7. Implement custom Prose components
- [ ] 7.1 Create ProseCode component with copy functionality
- Create app/components/content/ProseCode.vue
- Accept code, language, filename, highlights props
- Display language label if provided
- Display filename if provided
- Add copy button with icon
- Implement copy to clipboard using navigator.clipboard API
- Show success feedback (icon change or toast)
- Apply syntax highlighting theme based on color mode
- Support line highlighting from highlights prop
- _Requirements: 15.1, 15.2, 15.3, 15.4, 15.5, 15.6_
- [ ] 7.2 Create custom MDC components
- Create app/components/content/BlogCallout.vue for callout boxes
- Accept title and type props (info, warning, success)
- Use UCard with colored border based on type
- Create app/components/content/Alert.vue for inline alerts
- Test MDC syntax in sample blog posts (::blog-callout, ::alert)
- _Requirements: 5.6_
- [ ]* 7.3 Customize Prose component styles
- Update app.config.ts with prose customization
- Define styles for ProseH1, ProseH2, ProseH3 (font sizes, spacing, colors)
- Define styles for ProseP (line height, spacing, colors)
- Define styles for ProseCode inline code (background, padding, border-radius)
- Define styles for ProseA links (color, hover effects)
- Define styles for ProseImg (responsive, rounded corners)
- Test with sample posts to verify styling
- _Requirements: 5.7, 8.2_
- [ ] 8. Implement RSS feed generation
- Create server/routes/blog/rss.xml.ts server route
- Use serverQueryContent to fetch published posts for current locale
- Detect locale from URL path (/blog/rss.xml vs /fa/blog/rss.xml)
- Generate RSS 2.0 XML with channel metadata
- Include item elements for each post (title, link, guid, pubDate, description)
- Implement escapeXml helper function for XML safety
- Set Content-Type header to application/rss+xml
- Add RSS link to blog listing page header
- Test RSS feed in browser and RSS reader
- _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7_
- [ ] 9. Configure prerendering and route rules
- Update nitro.prerender.routes in nuxt.config.ts to include /blog and /fa/blog
- Add dynamic route generation for all blog posts using queryContent
- Verify route rules for caching are applied (/blog/**, /fa/blog/** with swr: 3600)
- Test static generation by running pnpm generate
- Verify all blog routes are prerendered in .output/public
- _Requirements: 10.5_
- [ ] 10. Add blog link to navigation
- Update app/components/common/TopNav.vue to include blog link
- Use localePath('/blog') for navigation
- Highlight blog link when on blog routes using useRoute()
- Add blog icon if desired
- Test navigation in both locales
- _Requirements: 16.3_
- [ ] 11. Create default blog cover image
- Create or add a default cover image to public/img/blog/default-cover.jpg
- Ensure image is optimized (WebP format, appropriate dimensions)
- Use this image as fallback in BlogCard and SEO meta tags
- _Requirements: 6.4_
- [ ]* 12. Performance optimization and testing
- Run Lighthouse audit on blog listing and detail pages
- Verify lazy loading of images below fold
- Check code splitting in network tab (separate chunks for blog components)
- Verify static generation of all routes
- Test hot-reload in development mode
- Measure and optimize Time to First Byte (TTFB)
- Verify Core Web Vitals (LCP, FID, CLS)
- _Requirements: 10.3, 10.4, 10.6, 10.7, 11.1_
- [ ]* 13. Accessibility testing and improvements
- Test keyboard navigation (Tab, Enter, arrow keys)
- Test with screen reader (NVDA or VoiceOver)
- Verify color contrast ratios using browser dev tools
- Ensure all interactive elements have focus indicators
- Add ARIA labels where needed
- Verify semantic HTML structure
- Test with reduced motion preference
- _Requirements: 8.3, 8.4, 8.5, 8.6_
- [ ]* 14. Cross-browser and responsive testing
- Test on Chrome, Firefox, Safari, Edge
- Test on mobile devices (iOS Safari, Chrome Android)
- Test on tablet viewports
- Verify RTL layout on Persian pages
- Test search and filter functionality on all devices
- Verify image optimization and lazy loading
- Test code block copy functionality
- _Requirements: 8.1, 9.1, 9.2, 9.4_
- [ ]* 15. Documentation and sample content
- Create README.md in content/ directory with authoring guidelines
- Document frontmatter schema and required fields
- Provide markdown examples for common elements
- Document MDC component usage
- Add comments in code for complex logic
- Update main README.md with blog feature description
- _Requirements: 11.3, 11.4_

View File

@@ -61,5 +61,8 @@
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"i18n", "i18n",
"i18n/locales" "i18n/locales"
] ],
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
} }

View File

@@ -0,0 +1,42 @@
<template>
<NuxtLink :to="localePath((post as any)._path)" class="block h-full">
<UCard class="h-full overflow-hidden hover:shadow-lg transition-shadow duration-300">
<div class="relative aspect-video w-full overflow-hidden -mx-6 -mt-6 mb-4">
<NuxtImg :src="post.image || '/img/blog/default-cover.jpg'" :alt="post.title" class="h-full w-full object-cover"
loading="lazy" width="600" height="338" format="webp" />
</div>
<h2 class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
{{ post.title }}
</h2>
<p class="mb-4 line-clamp-2 text-gray-600 dark:text-gray-400">
{{ post.description }}
</p>
<div class="mb-4 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-500">
<time :datetime="post.date">{{ formatDate(post.date) }}</time>
<span></span>
<span>{{ t('blog.readingTime', { minutes: calculateReadingTime(post) }) }}</span>
</div>
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-2">
<UBadge v-for="tag in post.tags" :key="tag" variant="subtle" size="sm">
{{ tag }}
</UBadge>
</div>
</UCard>
</NuxtLink>
</template>
<script setup lang="ts">
import type { BlogPost } from '~/types/blog'
defineProps<{
post: BlogPost
}>()
const { t } = useI18n()
const localePath = useLocalePath()
const { formatDate, calculateReadingTime } = useBlog()
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex min-h-[400px] items-center justify-center">
<div class="text-center">
<div class="mb-4 text-6xl">📝</div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
{{ t('blog.noResults') }}
</h3>
<p class="text-gray-600 dark:text-gray-400">
{{ t('blog.empty') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { BlogPost } from '~/types/blog'
const props = defineProps<{
prev: BlogPost | null
next: BlogPost | null
}>()
const { t } = useI18n()
const localePath = useLocalePath()
const router = useRouter()
// Keyboard navigation
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'ArrowLeft' && props.prev) {
router.push(localePath(props.prev._path))
} else if (event.key === 'ArrowRight' && props.next) {
router.push(localePath(props.next._path))
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<nav class="flex justify-between items-center gap-4 mt-12 pt-8 border-t border-gray-200 dark:border-gray-800">
<!-- Previous Post -->
<div class="flex-1">
<NuxtLink v-if="prev" :to="localePath(prev._path)" class="group block">
<UButton color="neutral" variant="ghost" size="lg" class="w-full justify-start">
<template #leading>
<UIcon name="i-heroicons-arrow-left" class="w-5 h-5" />
</template>
<div class="text-left">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{{ t('blog.previousPost') }}
</div>
<div
class="text-sm font-medium text-gray-900 dark:text-gray-100 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors line-clamp-1">
{{ prev.title }}
</div>
</div>
</UButton>
</NuxtLink>
</div>
<!-- Next Post -->
<div class="flex-1">
<NuxtLink v-if="next" :to="localePath(next._path)" class="group block">
<UButton color="neutral" variant="ghost" size="lg" class="w-full justify-end">
<div class="text-right">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{{ t('blog.nextPost') }}
</div>
<div
class="text-sm font-medium text-gray-900 dark:text-gray-100 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors line-clamp-1">
{{ next.title }}
</div>
</div>
<template #trailing>
<UIcon name="i-heroicons-arrow-right" class="w-5 h-5" />
</template>
</UButton>
</NuxtLink>
</div>
</nav>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { BlogPost } from '~/types/blog'
const props = defineProps<{
post: BlogPost
}>()
const { formatDate, calculateReadingTime } = useBlog()
const readingTime = computed(() => calculateReadingTime(props.post))
</script>
<template>
<article>
<header class="mb-8">
<!-- Cover Image -->
<NuxtImg v-if="post.image" :src="post.image" :alt="post.title" width="1200" height="630" format="webp"
class="w-full rounded-lg mb-6" />
<!-- Title -->
<h1 class="text-4xl md:text-5xl font-bold mb-4 text-gray-900 dark:text-gray-100">
{{ post.title }}
</h1>
<!-- Metadata -->
<div class="flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mb-4">
<!-- Date -->
<time :datetime="post.date" class="flex items-center gap-2">
<UIcon name="i-heroicons-calendar" class="w-5 h-5" />
{{ formatDate(post.date) }}
</time>
<!-- Reading Time -->
<span class="flex items-center gap-2">
<UIcon name="i-heroicons-clock" class="w-5 h-5" />
{{ $t('blog.readingTime', { minutes: readingTime }) }}
</span>
<!-- Author -->
<span v-if="post.author" class="flex items-center gap-2">
<UIcon name="i-heroicons-user" class="w-5 h-5" />
{{ post.author }}
</span>
</div>
<!-- Tags -->
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-2">
<UBadge v-for="tag in post.tags" :key="tag" color="primary" variant="soft" size="md">
{{ tag }}
</UBadge>
</div>
</header>
</article>
</template>

View File

@@ -0,0 +1,35 @@
<template>
<UInput :model-value="modelValue" :placeholder="t('blog.searchPlaceholder')" icon="i-heroicons-magnifying-glass"
size="lg" :ui="{ icon: { trailing: { pointer: '' } } }" @update:model-value="handleInput">
<template v-if="modelValue" #trailing>
<UButton color="gray" variant="link" icon="i-heroicons-x-mark-20-solid" :padded="false" @click="clearSearch" />
</template>
</UInput>
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { t } = useI18n()
// Debounced input handler
const debouncedEmit = useDebounceFn((value: string) => {
emit('update:modelValue', value)
}, 300)
const handleInput = (value: string) => {
debouncedEmit(value)
}
const clearSearch = () => {
emit('update:modelValue', '')
}
</script>

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
interface TocLink {
id: string
text: string
depth: number
children?: TocLink[]
}
const props = defineProps<{
toc: {
links: TocLink[]
}
}>()
const { t } = useI18n()
const activeId = ref<string>('')
// Check if TOC should be displayed (3+ headings)
const shouldShowToc = computed(() => {
const countLinks = (links: TocLink[]): number => {
return links.reduce((count, link) => {
return count + 1 + (link.children ? countLinks(link.children) : 0)
}, 0)
}
return countLinks(props.toc.links) >= 3
})
// Smooth scroll to heading
const scrollToHeading = (id: string) => {
const element = document.getElementById(id)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeId.value = id
}
}
// Track active section with IntersectionObserver
onMounted(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
activeId.value = entry.target.id
}
})
},
{
rootMargin: '-80px 0px -80% 0px',
threshold: 0
}
)
// Observe all headings
const headings = document.querySelectorAll('article h2, article h3, article h4')
headings.forEach((heading) => observer.observe(heading))
// Cleanup
onUnmounted(() => {
headings.forEach((heading) => observer.unobserve(heading))
})
})
// Render TOC links recursively
const renderTocLinks = (links: TocLink[]) => {
return links
}
</script>
<template>
<aside v-if="shouldShowToc" class="toc-container">
<!-- Desktop: Sticky sidebar -->
<nav class="hidden lg:block sticky top-24 max-h-[calc(100vh-8rem)] overflow-y-auto">
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
{{ t('blog.tableOfContents') }}
</h3>
<ul class="space-y-2 text-sm">
<template v-for="link in toc.links" :key="link.id">
<li>
<a :href="`#${link.id}`" :class="[
'block py-1 transition-colors',
activeId === link.id
? 'text-primary-600 dark:text-primary-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
]" @click.prevent="scrollToHeading(link.id)">
{{ link.text }}
</a>
<!-- Nested children (h3) -->
<ul v-if="link.children && link.children.length > 0" class="ml-4 mt-1 space-y-1">
<li v-for="child in link.children" :key="child.id">
<a :href="`#${child.id}`" :class="[
'block py-1 transition-colors',
activeId === child.id
? 'text-primary-600 dark:text-primary-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
]" @click.prevent="scrollToHeading(child.id)">
{{ child.text }}
</a>
</li>
</ul>
</li>
</template>
</ul>
</nav>
<!-- Mobile: Collapsible accordion -->
<UAccordion class="lg:hidden mb-6" :items="[
{
label: t('blog.tableOfContents'),
icon: 'i-heroicons-list-bullet',
defaultOpen: false,
slot: 'toc'
}
]">
<template #toc>
<ul class="space-y-2 text-sm p-4">
<template v-for="link in toc.links" :key="link.id">
<li>
<a :href="`#${link.id}`" :class="[
'block py-1 transition-colors',
activeId === link.id
? 'text-primary-600 dark:text-primary-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
]" @click.prevent="scrollToHeading(link.id)">
{{ link.text }}
</a>
<!-- Nested children (h3) -->
<ul v-if="link.children && link.children.length > 0" class="ml-4 mt-1 space-y-1">
<li v-for="child in link.children" :key="child.id">
<a :href="`#${child.id}`" :class="[
'block py-1 transition-colors',
activeId === child.id
? 'text-primary-600 dark:text-primary-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
]" @click.prevent="scrollToHeading(child.id)">
{{ child.text }}
</a>
</li>
</ul>
</li>
</template>
</ul>
</template>
</UAccordion>
</aside>
</template>
<style scoped>
.toc-container {
@apply w-full;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div v-if="tags.length > 0" class="overflow-x-auto">
<div class="flex flex-wrap gap-2">
<UButton :color="!modelValue ? 'primary' : 'gray'" :variant="!modelValue ? 'solid' : 'soft'" size="sm"
@click="selectTag(null)">
{{ t('blog.allPosts') }}
</UButton>
<UButton v-for="tag in tags" :key="tag" :color="modelValue === tag ? 'primary' : 'gray'"
:variant="modelValue === tag ? 'solid' : 'soft'" size="sm" @click="selectTag(tag)">
{{ tag }}
</UButton>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
tags: string[]
modelValue: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
// Read query parameter on mount to restore filter state
onMounted(() => {
const tagFromQuery = route.query.tag as string | undefined
if (tagFromQuery && props.tags.includes(tagFromQuery)) {
emit('update:modelValue', tagFromQuery)
}
})
// Select tag and update URL query parameter
const selectTag = async (tag: string | null) => {
emit('update:modelValue', tag)
// Update URL query parameter
await navigateTo({
query: tag ? { tag } : {}
})
}
</script>

View File

@@ -0,0 +1,88 @@
import type { BlogPost } from '~/types/blog'
export function useBlog() {
const { locale } = useI18n()
/**
* Calculate reading time from word count
* @param content - Blog post content
* @returns Reading time in minutes
*/
const calculateReadingTime = (content: any): number => {
if (!content?.body?.children) return 0
const text = JSON.stringify(content.body.children)
const wordCount = text.split(/\s+/).length
return Math.ceil(wordCount / 200) // 200 words per minute
}
/**
* Format date for display
* @param dateString - ISO 8601 date string
* @returns Formatted date string
*/
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return new Intl.DateTimeFormat(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date)
}
/**
* Get all unique tags from posts
* @param posts - Array of blog posts
* @returns Sorted array of unique tags
*/
const extractUniqueTags = (posts: BlogPost[]): string[] => {
const tagSet = new Set<string>()
posts.forEach(post => {
post.tags?.forEach(tag => tagSet.add(tag))
})
return Array.from(tagSet).sort()
}
/**
* Get blog path for current locale
* @returns Locale-aware blog path
*/
const getBlogPath = (): string => {
return `${locale.value}/blog`
}
/**
* Filter posts by search query
*/
const filterPostsBySearch = (posts: BlogPost[], query: string): BlogPost[] => {
if (!query) return posts
const lowerQuery = query.toLowerCase()
return posts.filter(post =>
post.title?.toLowerCase().includes(lowerQuery) ||
post.description?.toLowerCase().includes(lowerQuery) ||
post.tags?.some(tag => tag.toLowerCase().includes(lowerQuery))
)
}
/**
* Filter posts by tag
* @param posts - Array of blog posts
* @param tag - Tag to filter by
* @returns Filtered array of posts
*/
const filterPostsByTag = (posts: BlogPost[], tag: string | null): BlogPost[] => {
if (!tag) return posts
return posts.filter(post => post.tags?.includes(tag))
}
return {
calculateReadingTime,
formatDate,
extractUniqueTags,
getBlogPath,
filterPostsBySearch,
filterPostsByTag
}
}

View File

@@ -1,7 +1,180 @@
<script setup lang="ts">
import type { BlogPost } from '~/types/blog'
const { locale, t } = useI18n()
const localePath = useLocalePath()
const route = useRoute()
const slug = Array.isArray(route.params.slug) ? route.params.slug : [route.params.slug]
// Fetch current post
const { data: post } = await useAsyncData<BlogPost>(`blog-post-${slug.join('/')}`, () =>
queryContent<BlogPost>(`/${locale.value}/blog`)
.where({ _path: `/${locale.value}/blog/${slug.join('/')}` })
.findOne()
)
if (!post.value) {
throw createError({
statusCode: 404,
message: 'Blog post not found',
fatal: true
})
}
// Fetch all posts for prev/next navigation
const { data: allPosts } = await useAsyncData<BlogPost[]>(`blog-posts-nav-${locale.value}`, () =>
queryContent<BlogPost>(`/${locale.value}/blog`)
.where({ draft: { $ne: true } })
.sort({ date: -1 })
.only(['title', '_path', 'date'])
.find()
)
// Calculate adjacent posts
const currentIndex = computed(() => {
if (!allPosts.value || !post.value) return -1
return allPosts.value.findIndex((p: BlogPost) => p._path === post.value!._path)
})
const prevPost = computed<BlogPost | null>(() => {
if (currentIndex.value === -1 || !allPosts.value) return null
return allPosts.value[currentIndex.value + 1] || null
})
const nextPost = computed<BlogPost | null>(() => {
if (currentIndex.value === -1 || !allPosts.value) return null
return allPosts.value[currentIndex.value - 1] || null
})
// SEO meta tags
const siteUrl = 'https://aliarghyani.com' // TODO: Move to runtime config
// Use Nuxt Content's built-in SEO helper
if (post.value) {
useContentHead(post as any)
}
// Additional custom meta tags
if (post.value) {
useSeoMeta({
title: `${post.value.title} | ${t('blog.title')}`,
description: post.value.description,
ogTitle: post.value.title,
ogDescription: post.value.description,
ogImage: post.value.image || '/img/blog/default-cover.jpg',
ogType: 'article',
ogUrl: `${siteUrl}${post.value._path}`,
twitterCard: 'summary_large_image',
twitterTitle: post.value.title,
twitterDescription: post.value.description,
twitterImage: post.value.image || '/img/blog/default-cover.jpg',
articlePublishedTime: post.value.date,
articleModifiedTime: post.value.updatedAt || post.value.date,
articleAuthor: post.value.author || 'Ali Arghyani',
articleTag: post.value.tags
})
// JSON-LD structured data
useHead({
script: [
{
type: 'application/ld+json',
textContent: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.value.title,
description: post.value.description,
image: post.value.image ? `${siteUrl}${post.value.image}` : `${siteUrl}/img/blog/default-cover.jpg`,
datePublished: post.value.date,
dateModified: post.value.updatedAt || post.value.date,
author: {
'@type': 'Person',
name: post.value.author || 'Ali Arghyani'
},
publisher: {
'@type': 'Person',
name: 'Ali Arghyani'
}
})
}
]
})
}
</script>
<template> <template>
<UContainer> <UContainer>
<UAlert color="yellow" variant="soft" title="Blog disabled"> <div v-if="post" class="py-8">
Nuxt Content is temporarily disabled. Blog post rendering will be restored later. <!-- Breadcrumb Navigation -->
</UAlert> <UBreadcrumb :links="[
{ label: t('nav.home'), to: localePath('/') },
{ label: t('blog.title'), to: localePath('/blog') },
{ label: post.title }
]" class="mb-6" />
<!-- Back to Blog Link -->
<NuxtLink :to="localePath('/blog')"
class="inline-flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors mb-8">
<UIcon name="i-heroicons-arrow-left" class="w-4 h-4" />
{{ t('blog.backToBlog') }}
</NuxtLink>
<!-- Main Content Layout -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- Main Content -->
<div class="lg:col-span-8">
<!-- Blog Post Metadata -->
<BlogPost :post="post" />
<!-- Content Renderer -->
<article :dir="locale === 'fa' ? 'rtl' : 'ltr'" class="prose prose-lg dark:prose-invert max-w-none mt-8">
<ContentRenderer :value="post" />
</article>
<!-- Blog Navigation (Prev/Next) -->
<BlogNavigation :prev="prevPost" :next="nextPost" />
</div>
<!-- Sidebar: Table of Contents -->
<aside class="lg:col-span-4">
<BlogTableOfContents v-if="post.body?.toc" :toc="post.body.toc" />
</aside>
</div>
</div>
</UContainer> </UContainer>
</template> </template>
<style scoped>
/* Force LTR for code blocks in RTL context */
article[dir="rtl"] :deep(pre),
article[dir="rtl"] :deep(code) {
direction: ltr;
text-align: left;
}
/* Ensure proper spacing and readability */
.prose {
@apply text-gray-700 dark:text-gray-300;
}
.prose :deep(h1),
.prose :deep(h2),
.prose :deep(h3),
.prose :deep(h4),
.prose :deep(h5),
.prose :deep(h6) {
@apply text-gray-900 dark:text-gray-100 font-semibold;
}
.prose :deep(a) {
@apply text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300;
}
.prose :deep(code) {
@apply text-sm;
}
.prose :deep(pre) {
@apply rounded-lg;
}
</style>

View File

@@ -8,15 +8,73 @@
</div> </div>
</div> </div>
<div class="rounded-xl border border-dashed border-gray-300 p-8 text-center text-gray-500 dark:border-gray-700 dark:text-gray-300"> <BlogSearch v-model="searchQuery" />
Nuxt Content is temporarily disabled. Blog will be back soon.
<BlogTagFilter v-model="selectedTag" :tags="allTags" class="my-6" />
<div v-if="filteredPosts.length > 0" class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<BlogCard v-for="post in filteredPosts" :key="(post as any)._path" :post="post" />
</div> </div>
<BlogEmpty v-else />
</UContainer> </UContainer>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { locale, t } = useI18n() import type { BlogPost } from '~/types/blog'
// Blog listing requires @nuxt/content which is disabled for now const { t, locale } = useI18n()
const { extractUniqueTags, filterPostsBySearch, filterPostsByTag } = useBlog()
// Fetch posts using queryCollection (Nuxt Content v3 API)
const { data: posts } = await useAsyncData<any[]>(
`blog-posts-${locale.value}`,
async () => {
try {
// Use queryCollection for Nuxt Content v3
const result = await queryCollection('blog').all()
if (!result || result.length === 0) {
return []
}
// Filter by locale and draft status, then sort by date
return result
.filter((post: any) =>
post.path?.startsWith(`/${locale.value}/blog/`) && post.draft !== true
)
.sort((a: any, b: any) => new Date(b.date).getTime() - new Date(a.date).getTime())
} catch (error) {
console.error('Error fetching blog posts:', error)
return []
}
},
{
default: () => [],
watch: [locale],
server: true,
lazy: false
}
)
// Extract unique tags from all posts
const allTags = computed(() => {
return posts.value && Array.isArray(posts.value) ? extractUniqueTags(posts.value) : []
})
// Reactive refs for search and filter
const searchQuery = ref('')
const selectedTag = ref<string | null>(null)
// Filtered posts based on search and tag
const filteredPosts = computed<BlogPost[]>(() => {
if (!posts.value || !Array.isArray(posts.value) || posts.value.length === 0) return []
let filtered: BlogPost[] = [...posts.value]
filtered = filterPostsBySearch(filtered, searchQuery.value)
filtered = filterPostsByTag(filtered, selectedTag.value)
return filtered
})
</script> </script>

44
app/types/blog.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { ParsedContent } from '@nuxt/content'
export interface BlogPost extends ParsedContent {
// Required fields
title: string
description: string
date: string // ISO 8601 format
tags: string[]
_path: string
// Optional fields
image?: string // Cover image path
author?: string
draft?: boolean
updatedAt?: string
// Custom SEO
head?: {
title?: string
description?: string
image?: string
}
// Body with TOC
body?: {
type: string
children: any[]
toc?: {
title: string
searchDepth: number
depth: number
links: Array<{
id: string
text: string
depth: number
children?: Array<{
id: string
text: string
depth: number
}>
}>
}
}
}

20
content.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
export default defineContentConfig({
collections: {
blog: defineCollection({
type: 'page',
source: '**/*.md',
schema: z.object({
title: z.string(),
description: z.string(),
date: z.string(),
tags: z.array(z.string()),
image: z.string().optional(),
author: z.string().optional(),
draft: z.boolean().optional(),
updatedAt: z.string().optional()
})
})
}
})

View File

@@ -0,0 +1,14 @@
---
title: "This is a Draft Post"
description: "This post is in draft mode and should not appear in production."
date: "2024-11-10"
tags: ["draft", "test"]
author: "Ali Arghyani"
draft: true
---
# Draft Post
This is a draft post that should only be visible in development mode.
It will be filtered out in production using the `draft: true` frontmatter field.

View File

@@ -0,0 +1,108 @@
---
title: "Getting Started with Nuxt Content"
description: "Learn how to build a powerful blog with Nuxt Content v3, featuring markdown support, syntax highlighting, and Vue component integration."
date: "2024-11-09"
tags: ["nuxt", "vue", "typescript", "tutorial"]
image: "/img/blog/nuxt-content-cover.jpg"
author: "Ali Arghyani"
draft: false
---
# Getting Started with Nuxt Content
Nuxt Content is a powerful file-based CMS that allows you to write content in Markdown, YAML, CSV, or JSON and query it with a MongoDB-like API. In this tutorial, we'll explore how to set up and use Nuxt Content v3 in your Nuxt 4 application.
## Why Nuxt Content?
Nuxt Content offers several advantages for content-driven applications:
- **File-based**: Write content in Markdown files with Git version control
- **Type-safe**: Full TypeScript support with auto-generated types
- **Powerful queries**: MongoDB-like API for filtering and sorting
- **Syntax highlighting**: Built-in code highlighting with Shiki
- **MDC syntax**: Embed Vue components directly in Markdown
## Installation
Installing Nuxt Content is straightforward:
```bash
pnpm add @nuxt/content
```
Then add it to your `nuxt.config.ts`:
```typescript
export default defineNuxtConfig({
modules: ['@nuxt/content']
})
```
## Creating Content
Create a `content/` directory in your project root and start writing Markdown files:
```markdown
---
title: "My First Post"
description: "This is my first blog post"
date: "2024-11-09"
---
# Hello World
This is my first post using Nuxt Content!
```
## Querying Content
Use the `queryContent()` composable to fetch your content:
```vue
<script setup>
const { data: posts } = await useAsyncData('posts', () =>
queryContent('blog')
.sort({ date: -1 })
.find()
)
</script>
```
## Rendering Content
Use the `ContentRenderer` component to render your Markdown:
```vue
<template>
<ContentRenderer :value="post" />
</template>
```
## Advanced Features
### Code Highlighting
Nuxt Content uses Shiki for beautiful syntax highlighting:
```javascript
// This code will be highlighted automatically
const greeting = (name) => {
console.log(`Hello, ${name}!`)
}
```
### MDC Components
You can use Vue components in your Markdown:
```markdown
::alert{type="info"}
This is an informational alert!
::
```
## Conclusion
Nuxt Content provides a powerful and flexible way to manage content in your Nuxt applications. With its file-based approach, powerful querying capabilities, and seamless Vue integration, it's perfect for blogs, documentation sites, and content-heavy applications.
Happy coding! 🚀

View File

@@ -0,0 +1,133 @@
---
title: "TypeScript Best Practices for Vue 3"
description: "Discover essential TypeScript patterns and best practices for building type-safe Vue 3 applications with Composition API."
date: "2024-11-08"
tags: ["typescript", "vue", "best-practices", "composition-api"]
image: "/img/blog/typescript-vue.jpg"
author: "Ali Arghyani"
draft: false
---
# TypeScript Best Practices for Vue 3
TypeScript has become an essential tool for building robust Vue 3 applications. In this guide, we'll explore best practices for leveraging TypeScript's type system with Vue 3's Composition API.
## Type-Safe Props
Define props with proper TypeScript interfaces:
```vue
<script setup lang="ts">
interface Props {
title: string
count?: number
items: string[]
}
const props = defineProps<Props>()
</script>
```
## Typed Composables
Create reusable composables with full type safety:
```typescript
export function useCounter(initialValue = 0) {
const count = ref<number>(initialValue)
const increment = (): void => {
count.value++
}
const decrement = (): void => {
count.value--
}
return {
count: readonly(count),
increment,
decrement
}
}
```
## Generic Components
Build flexible components with generics:
```vue
<script setup lang="ts" generic="T extends { id: string }">
interface Props {
items: T[]
onSelect: (item: T) => void
}
const props = defineProps<Props>()
</script>
```
## Type-Safe Event Emits
Define emits with proper typing:
```vue
<script setup lang="ts">
interface Emits {
(e: 'update', value: string): void
(e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
</script>
```
## Utility Types
Leverage TypeScript utility types:
```typescript
// Pick specific properties
type UserPreview = Pick<User, 'id' | 'name' | 'email'>
// Make all properties optional
type PartialUser = Partial<User>
// Make all properties required
type RequiredUser = Required<User>
// Exclude properties
type UserWithoutPassword = Omit<User, 'password'>
```
## Type Guards
Implement type guards for runtime type checking:
```typescript
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
)
}
```
## Async Data Typing
Type your async data properly:
```typescript
const { data, pending, error } = await useAsyncData<User[]>(
'users',
() => $fetch('/api/users')
)
```
## Conclusion
TypeScript enhances Vue 3 development by providing type safety, better IDE support, and improved code maintainability. By following these best practices, you'll build more robust and maintainable applications.
Remember: TypeScript is a tool to help you, not hinder you. Start simple and gradually add more type safety as needed.

View File

@@ -0,0 +1,108 @@
---
title: "آشنایی با Nuxt Content"
description: "یاد بگیرید چگونه با Nuxt Content یک وبلاگ قدرتمند بسازید که از مارک‌داون، هایلایت کد و کامپوننت‌های Vue پشتیبانی می‌کند."
date: "2024-11-09"
tags: ["nuxt", "vue", "آموزش", "فارسی"]
image: "/img/blog/nuxt-content-cover.jpg"
author: "علی ارغیانی"
draft: false
---
# آشنایی با Nuxt Content
Nuxt Content یک سیستم مدیریت محتوای فایل‌محور قدرتمند است که به شما امکان می‌دهد محتوای خود را به صورت Markdown، YAML، CSV یا JSON بنویسید و با یک API شبیه MongoDB آن را جستجو کنید.
## چرا Nuxt Content؟
Nuxt Content مزایای متعددی برای برنامه‌های محتوا-محور ارائه می‌دهد:
- **فایل-محور**: محتوا را در فایل‌های Markdown با کنترل نسخه Git بنویسید
- **Type-safe**: پشتیبانی کامل از TypeScript با تایپ‌های خودکار
- **جستجوی قدرتمند**: API شبیه MongoDB برای فیلتر و مرتب‌سازی
- **هایلایت کد**: هایلایت خودکار کد با Shiki
- **سینتکس MDC**: استفاده از کامپوننت‌های Vue مستقیماً در Markdown
## نصب
نصب Nuxt Content بسیار ساده است:
```bash
pnpm add @nuxt/content
```
سپس آن را به `nuxt.config.ts` اضافه کنید:
```typescript
export default defineNuxtConfig({
modules: ['@nuxt/content']
})
```
## ایجاد محتوا
یک دایرکتوری `content/` در ریشه پروژه خود ایجاد کنید و شروع به نوشتن فایل‌های Markdown کنید:
```markdown
---
title: "اولین پست من"
description: "این اولین پست وبلاگ من است"
date: "2024-11-09"
---
# سلام دنیا
این اولین پست من با استفاده از Nuxt Content است!
```
## جستجوی محتوا
از composable `queryContent()` برای دریافت محتوا استفاده کنید:
```vue
<script setup>
const { data: posts } = await useAsyncData('posts', () =>
queryContent('blog')
.sort({ date: -1 })
.find()
)
</script>
```
## رندر کردن محتوا
از کامپوننت `ContentRenderer` برای رندر Markdown استفاده کنید:
```vue
<template>
<ContentRenderer :value="post" />
</template>
```
## ویژگی‌های پیشرفته
### هایلایت کد
Nuxt Content از Shiki برای هایلایت زیبای کد استفاده می‌کند:
```javascript
// این کد به صورت خودکار هایلایت می‌شود
const greeting = (name) => {
console.log(`سلام، ${name}!`)
}
```
### کامپوننت‌های MDC
می‌توانید از کامپوننت‌های Vue در Markdown خود استفاده کنید:
```markdown
::alert{type="info"}
این یک هشدار اطلاعاتی است!
::
```
## نتیجه‌گیری
Nuxt Content یک راه قدرتمند و انعطاف‌پذیر برای مدیریت محتوا در برنامه‌های Nuxt شما فراهم می‌کند. با رویکرد فایل-محور، قابلیت‌های جستجوی قدرتمند و یکپارچگی یکپارچه با Vue، برای وبلاگ‌ها، سایت‌های مستندات و برنامه‌های محتوا-محور عالی است.
کدنویسی خوشحالی! 🚀

View File

@@ -0,0 +1,179 @@
---
title: "راهنمای Composition API در Vue 3"
description: "آموزش کامل Composition API در Vue 3 با مثال‌های کاربردی و بهترین روش‌های پیاده‌سازی."
date: "2024-11-07"
tags: ["vue", "composition-api", "آموزش", "فارسی"]
image: "/img/blog/vue-composition.jpg"
author: "علی ارغیانی"
draft: false
---
# راهنمای Composition API در Vue 3
Composition API یکی از مهم‌ترین ویژگی‌های Vue 3 است که روش جدیدی برای سازماندهی و استفاده مجدد از منطق کامپوننت‌ها ارائه می‌دهد.
## چرا Composition API؟
Composition API مشکلات Options API را حل می‌کند:
- **سازماندهی بهتر**: منطق مرتبط را در کنار هم نگه دارید
- **استفاده مجدد**: composable ها را به راحتی به اشتراک بگذارید
- **Type Safety**: پشتیبانی بهتر از TypeScript
- **خوانایی**: کد تمیزتر و قابل فهم‌تر
## مفاهیم پایه
### Reactive State
برای ایجاد state واکنش‌پذیر از `ref` یا `reactive` استفاده کنید:
```vue
<script setup>
import { ref, reactive } from 'vue'
// با ref
const count = ref(0)
// با reactive
const state = reactive({
name: 'علی',
age: 25
})
</script>
```
### Computed Properties
برای مقادیر محاسبه‌شده از `computed` استفاده کنید:
```vue
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('علی')
const lastName = ref('ارغیانی')
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
</script>
```
### Watchers
برای نظارت بر تغییرات از `watch` یا `watchEffect` استفاده کنید:
```vue
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newValue, oldValue) => {
console.log(`تغییر از ${oldValue} به ${newValue}`)
})
</script>
```
## Composables
composable ها توابع قابل استفاده مجددی هستند که منطق stateful را کپسوله می‌کنند:
```typescript
// composables/useCounter.ts
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => {
count.value++
}
const decrement = () => {
count.value--
}
const reset = () => {
count.value = initialValue
}
return {
count: readonly(count),
increment,
decrement,
reset
}
}
```
استفاده از composable:
```vue
<script setup>
const { count, increment, decrement } = useCounter(10)
</script>
<template>
<div>
<p>شمارنده: {{ count }}</p>
<button @click="increment">افزایش</button>
<button @click="decrement">کاهش</button>
</div>
</template>
```
## Lifecycle Hooks
از lifecycle hooks در `<script setup>` استفاده کنید:
```vue
<script setup>
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
console.log('کامپوننت mount شد')
})
onUnmounted(() => {
console.log('کامپوننت unmount شد')
})
</script>
```
## بهترین روش‌ها
### 1. استفاده از `<script setup>`
این سینتکس مختصرتر و بهینه‌تر است:
```vue
<script setup>
// کد شما اینجا
</script>
```
### 2. سازماندهی Composables
composable ها را در دایرکتوری `composables/` قرار دهید:
```
composables/
├── useAuth.ts
├── useCounter.ts
└── useFetch.ts
```
### 3. نام‌گذاری
composable ها را با `use` شروع کنید:
```typescript
export function useMyFeature() {
// ...
}
```
## نتیجه‌گیری
Composition API روش قدرتمندی برای نوشتن کامپوننت‌های Vue است که کد را قابل نگهداری‌تر، قابل استفاده مجددتر و type-safe تر می‌کند.
با تمرین و استفاده مداوم، Composition API به بخش طبیعی از توسعه Vue شما تبدیل خواهد شد.

View File

@@ -102,9 +102,24 @@
"theme": "Theme" "theme": "Theme"
}, },
"blog": { "blog": {
"title": "Blog",
"explore": "Stories & notes", "explore": "Stories & notes",
"empty": "No posts yet. Check back soon!", "empty": "No posts yet. Check back soon!",
"readMore": "Read article" "readMore": "Read article",
"readingTime": "{minutes} min read",
"publishedOn": "Published on",
"updatedOn": "Updated on",
"backToBlog": "Back to Blog",
"previousPost": "Previous",
"nextPost": "Next",
"tableOfContents": "Table of Contents",
"searchPlaceholder": "Search posts...",
"filterByTag": "Filter by tag",
"allPosts": "All posts",
"noResults": "No posts found matching your search",
"copyCode": "Copy code",
"codeCopied": "Copied!",
"subscribe": "Subscribe via RSS"
}, },
"common": { "common": {
"present": "Present", "present": "Present",

View File

@@ -72,9 +72,24 @@
"theme": "تم" "theme": "تم"
}, },
"blog": { "blog": {
"title": "وبلاگ",
"explore": "داستان‌ها و یادداشت‌ها", "explore": "داستان‌ها و یادداشت‌ها",
"empty": "فعلاً نوشته‌ای وجود ندارد.", "empty": "فعلاً نوشته‌ای وجود ندارد.",
"readMore": "مطالعه" "readMore": "مطالعه",
"readingTime": "{minutes} دقیقه مطالعه",
"publishedOn": "منتشر شده در",
"updatedOn": "به‌روزرسانی شده در",
"backToBlog": "بازگشت به وبلاگ",
"previousPost": "قبلی",
"nextPost": "بعدی",
"tableOfContents": "فهرست مطالب",
"searchPlaceholder": "جستجوی مقالات...",
"filterByTag": "فیلتر بر اساس برچسب",
"allPosts": "همه مقالات",
"noResults": "نتیجه‌ای یافت نشد",
"copyCode": "کپی کد",
"codeCopied": "کپی شد!",
"subscribe": "اشتراک از طریق RSS"
}, },
"common": { "common": {
"present": "اکنون", "present": "اکنون",

View File

@@ -2,6 +2,7 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
srcDir: 'app', srcDir: 'app',
modules: [ modules: [
'@nuxt/content',
'@nuxt/fonts', '@nuxt/fonts',
'@nuxt/ui', '@nuxt/ui',
'@nuxtjs/i18n', '@nuxtjs/i18n',
@@ -92,7 +93,29 @@ export default defineNuxtConfig({
storageKey: "nuxt-color-mode", storageKey: "nuxt-color-mode",
}, },
// // Nuxt Content configuration
content: {
// Highlight code blocks with Shiki
highlight: {
theme: {
default: 'github-light',
dark: 'github-dark'
},
preload: ['typescript', 'javascript', 'vue', 'css', 'bash', 'json', 'markdown']
},
// Enable MDC syntax for Vue components in markdown
markdown: {
mdc: true,
toc: {
depth: 3,
searchDepth: 3
}
},
// Document-driven mode disabled (we use custom pages)
documentDriven: false,
// Respect path case
respectPathCase: true
},
i18n: { i18n: {
@@ -121,6 +144,15 @@ export default defineNuxtConfig({
}, },
}, },
// Route rules for caching and optimization
routeRules: {
// Blog routes caching
'/blog': { swr: 3600 },
'/fa/blog': { swr: 3600 },
'/blog/**': { swr: 3600 },
'/fa/blog/**': { swr: 3600 }
},
devtools: { enabled: false }, devtools: { enabled: false },
compatibilityDate: "2024-07-10", compatibilityDate: "2024-07-10",

View File

@@ -0,0 +1,12 @@
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#grad)"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="64" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="middle">
Blog Post
</text>
</svg>

After

Width:  |  Height:  |  Size: 552 B