mirror of
https://github.com/mmahdium/portfolio.git
synced 2025-12-20 09:23:54 +01:00
add blog to project
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
.data
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
|
||||
1321
.kiro/specs/nuxt-content-blog/design.md
Normal file
1321
.kiro/specs/nuxt-content-blog/design.md
Normal file
File diff suppressed because it is too large
Load Diff
241
.kiro/specs/nuxt-content-blog/requirements.md
Normal file
241
.kiro/specs/nuxt-content-blog/requirements.md
Normal 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
|
||||
315
.kiro/specs/nuxt-content-blog/tasks.md
Normal file
315
.kiro/specs/nuxt-content-blog/tasks.md
Normal 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_
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -61,5 +61,8 @@
|
||||
"i18n-ally.localesPaths": [
|
||||
"i18n",
|
||||
"i18n/locales"
|
||||
]
|
||||
],
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
}
|
||||
|
||||
42
app/components/blog/BlogCard.vue
Normal file
42
app/components/blog/BlogCard.vue
Normal 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>
|
||||
17
app/components/blog/BlogEmpty.vue
Normal file
17
app/components/blog/BlogEmpty.vue
Normal 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>
|
||||
73
app/components/blog/BlogNavigation.vue
Normal file
73
app/components/blog/BlogNavigation.vue
Normal 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>
|
||||
53
app/components/blog/BlogPost.vue
Normal file
53
app/components/blog/BlogPost.vue
Normal 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>
|
||||
35
app/components/blog/BlogSearch.vue
Normal file
35
app/components/blog/BlogSearch.vue
Normal 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>
|
||||
151
app/components/blog/BlogTableOfContents.vue
Normal file
151
app/components/blog/BlogTableOfContents.vue
Normal 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>
|
||||
48
app/components/blog/BlogTagFilter.vue
Normal file
48
app/components/blog/BlogTagFilter.vue
Normal 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>
|
||||
88
app/composables/useBlog.ts
Normal file
88
app/composables/useBlog.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<UContainer>
|
||||
<UAlert color="yellow" variant="soft" title="Blog disabled">
|
||||
Nuxt Content is temporarily disabled. Blog post rendering will be restored later.
|
||||
</UAlert>
|
||||
<div v-if="post" class="py-8">
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -8,15 +8,73 @@
|
||||
</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">
|
||||
Nuxt Content is temporarily disabled. Blog will be back soon.
|
||||
<BlogSearch v-model="searchQuery" />
|
||||
|
||||
<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>
|
||||
|
||||
<BlogEmpty v-else />
|
||||
</UContainer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
44
app/types/blog.ts
Normal file
44
app/types/blog.ts
Normal 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
20
content.config.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
14
content/en/blog/draft-post.md
Normal file
14
content/en/blog/draft-post.md
Normal 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.
|
||||
108
content/en/blog/getting-started-with-nuxt-content.md
Normal file
108
content/en/blog/getting-started-with-nuxt-content.md
Normal 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! 🚀
|
||||
133
content/en/blog/typescript-best-practices.md
Normal file
133
content/en/blog/typescript-best-practices.md
Normal 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.
|
||||
108
content/fa/blog/nuxt-content-introduction.md
Normal file
108
content/fa/blog/nuxt-content-introduction.md
Normal 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، برای وبلاگها، سایتهای مستندات و برنامههای محتوا-محور عالی است.
|
||||
|
||||
کدنویسی خوشحالی! 🚀
|
||||
179
content/fa/blog/vue-composition-api.md
Normal file
179
content/fa/blog/vue-composition-api.md
Normal 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 شما تبدیل خواهد شد.
|
||||
@@ -102,9 +102,24 @@
|
||||
"theme": "Theme"
|
||||
},
|
||||
"blog": {
|
||||
"title": "Blog",
|
||||
"explore": "Stories & notes",
|
||||
"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": {
|
||||
"present": "Present",
|
||||
|
||||
@@ -72,9 +72,24 @@
|
||||
"theme": "تم"
|
||||
},
|
||||
"blog": {
|
||||
"title": "وبلاگ",
|
||||
"explore": "داستانها و یادداشتها",
|
||||
"empty": "فعلاً نوشتهای وجود ندارد.",
|
||||
"readMore": "مطالعه"
|
||||
"readMore": "مطالعه",
|
||||
"readingTime": "{minutes} دقیقه مطالعه",
|
||||
"publishedOn": "منتشر شده در",
|
||||
"updatedOn": "بهروزرسانی شده در",
|
||||
"backToBlog": "بازگشت به وبلاگ",
|
||||
"previousPost": "قبلی",
|
||||
"nextPost": "بعدی",
|
||||
"tableOfContents": "فهرست مطالب",
|
||||
"searchPlaceholder": "جستجوی مقالات...",
|
||||
"filterByTag": "فیلتر بر اساس برچسب",
|
||||
"allPosts": "همه مقالات",
|
||||
"noResults": "نتیجهای یافت نشد",
|
||||
"copyCode": "کپی کد",
|
||||
"codeCopied": "کپی شد!",
|
||||
"subscribe": "اشتراک از طریق RSS"
|
||||
},
|
||||
"common": {
|
||||
"present": "اکنون",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export default defineNuxtConfig({
|
||||
srcDir: 'app',
|
||||
modules: [
|
||||
'@nuxt/content',
|
||||
'@nuxt/fonts',
|
||||
'@nuxt/ui',
|
||||
'@nuxtjs/i18n',
|
||||
@@ -92,7 +93,29 @@ export default defineNuxtConfig({
|
||||
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: {
|
||||
@@ -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 },
|
||||
compatibilityDate: "2024-07-10",
|
||||
|
||||
|
||||
12
public/img/blog/default-cover.svg
Normal file
12
public/img/blog/default-cover.svg
Normal 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 |
Reference in New Issue
Block a user