Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/components/blog/RelatedPosts.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
import type { CollectionEntry } from 'astro:content';

import BlogPostCard from './BlogPostCard.astro';

type Props = {
relatedPosts: CollectionEntry<'blog'>[];
};

const { relatedPosts } = Astro.props;
---

{
relatedPosts.length > 0 && (
<section class="related-posts">
<h2 class="related-posts-heading">You might also like</h2>
<div class="related-posts-grid">
{relatedPosts.map(post => (
<div class="grid-item" data-post-id={post.slug}>
<BlogPostCard post={post} />
</div>
))}
</div>
</section>
)
}

<style>
.related-posts {
margin-top: 4rem;
padding-top: 3rem;
border-top: 1px solid var(--card-border-color);
}

.related-posts-heading {
font-family: var(--font-serif);
font-size: clamp(1.3rem, 4vw, 1.6rem);
text-align: center;
margin-bottom: 2rem;
color: var(--font-color);
}

.related-posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}

.grid-item {
height: 100%;
}

@media (max-width: 1024px) {
.related-posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}

@media (max-width: 768px) {
.related-posts {
margin-top: 3rem;
padding-top: 2rem;
}

.related-posts-grid {
grid-template-columns: 1fr;
gap: 1.25rem;
}

.related-posts-heading {
margin-bottom: 1.5rem;
}
}
</style>
11 changes: 10 additions & 1 deletion src/layouts/BlogPostLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
import type { MarkdownHeading } from 'astro';
import type { CollectionEntry } from 'astro:content';

import RelatedPosts from '@/components/blog/RelatedPosts.astro';
import Toc from '@/components/blog/Toc.astro';
import ViewCounter from '@/components/blog/ViewCounter.astro';
import { getAllBlogPosts } from '@/lib/blog';
import { getRelatedPosts } from '@/utils/post-utils';
import { slugify } from '@/utils/slugify';

import BaseLayout from './BaseLayout.astro';
Expand All @@ -23,8 +26,12 @@ const formattedDate = new Date(pubDate).toLocaleDateString('en-US', {
day: 'numeric',
});

// ใช้ slug ที่ส่งมา หรือสร้างจาก title เพื่อใช้เป็น key ในฐานข้อมูล
// Use the provided slug or generate from title as a database key
const postSlug = slug || slugify(title);

// Fetch all blog posts (cached) and compute related posts
const allPosts = await getAllBlogPosts();
const relatedPosts = getRelatedPosts(allPosts, postSlug, tags, category, 3);
---

<BaseLayout title={title} description={description}>
Expand Down Expand Up @@ -72,6 +79,8 @@ const postSlug = slug || slugify(title);
}
</div>
</footer>

<RelatedPosts relatedPosts={relatedPosts} />
</article>

<aside class="right-sidebar">
Expand Down
22 changes: 22 additions & 0 deletions src/lib/blog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { CollectionEntry } from 'astro:content';

import { getCollection } from 'astro:content';

/**
* Cached blog posts collection.
* Since Astro pre-renders pages at build time, this module-level cache
* ensures the collection is fetched only once during the build process,
* avoiding redundant queries across multiple page renders.
*/
let cachedPosts: CollectionEntry<'blog'>[] | null = null;

/**
* Get all blog posts with caching.
* Results are cached at the module level to avoid repeated collection fetches.
*/
export async function getAllBlogPosts(): Promise<CollectionEntry<'blog'>[]> {
if (cachedPosts === null) {
cachedPosts = await getCollection('blog');
}
return cachedPosts;
}
73 changes: 73 additions & 0 deletions src/utils/post-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { CollectionEntry } from 'astro:content';

/**
* Safely parse a date, returning 0 for invalid dates
*/
function safeGetTime(date: Date | string | undefined): number {
if (!date)
return 0;
const parsed = new Date(date);
return Number.isNaN(parsed.getTime()) ? 0 : parsed.getTime();
}

/**
* Calculate related posts based on tags and category matching.
* Uses a scoring system: +2 points per matching tag, +1 point for matching category.
* Falls back to most recent posts if no related posts are found.
*/
export function getRelatedPosts(
allPosts: CollectionEntry<'blog'>[],
currentSlug: string,
currentTags: string[] = [],
currentCategory = '',
limit = 3,
): CollectionEntry<'blog'>[] {
// Filter out the current post and posts without valid pubDate
const otherPosts = allPosts.filter(
post => post.slug !== currentSlug && post.data.pubDate,
);
Comment on lines +26 to +28
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for getAllBlogPosts implementation
echo "=== Searching for getAllBlogPosts ==="
rg -n --type=ts "getAllBlogPosts" --max-count=10

echo ""
echo "=== Checking post-utils.ts context ==="
# Read the file to see the actual implementation context
cat -n src/utils/post-utils.ts | head -50

echo ""
echo "=== Searching for draft filtering logic ==="
rg -n --type=ts -C3 "draft|published" src/

Repository: pharmacist-sabot/rxdevnotes

Length of output: 2075


🏁 Script executed:

#!/bin/bash
# Read the getAllBlogPosts implementation
echo "=== getAllBlogPosts implementation ==="
cat -n src/lib/blog.ts

echo ""
echo "=== Search for content schema definition ==="
fd -e ts -e tsx "schema\|config" src/ | head -10

echo ""
echo "=== Look for blog collection definition ==="
rg -n "type.*blog\|interface.*blog" --type=ts -i src/

Repository: pharmacist-sabot/rxdevnotes

Length of output: 1069


🏁 Script executed:

#!/bin/bash
# Find Astro content config file
echo "=== Looking for Astro content configuration ==="
fd -name "config.ts" src/content/

echo ""
echo "=== Check directory structure of src/content ==="
find src/content -type f | head -20

echo ""
echo "=== Search for any draft-related filtering in getCollection calls ==="
rg -n "getCollection" --type=ts -A5 -B5 src/

Repository: pharmacist-sabot/rxdevnotes

Length of output: 2219


🏁 Script executed:

#!/bin/bash
# Read the Astro content config
echo "=== Astro content config ==="
cat -n src/content/config.ts

echo ""
echo "=== Check for draft field in blog frontmatter ==="
# Sample one blog post to see the frontmatter
head -50 src/content/blog/automate-release-with-semantic-release/index.mdx

Repository: pharmacist-sabot/rxdevnotes

Length of output: 3191


Consider adding draft post support if needed. The codebase currently lacks a draft field in the blog collection schema and has no draft filtering logic. If draft posts are needed, add a draft: z.boolean().optional() field to the schema in src/content/config.ts and implement filtering in getAllBlogPosts() to exclude them (e.g., await getCollection('blog').then(posts => posts.filter(p => !p.data.draft))). Currently, the getRelatedPosts() function only filters by pubDate and slug, which is insufficient for draft post handling.

🤖 Prompt for AI Agents
In src/utils/post-utils.ts around lines 26 to 28, getRelatedPosts currently
filters only by slug and pubDate and will include draft posts if drafts exist;
add draft handling by adding a draft: z.boolean().optional() to the blog
collection schema in src/content/config.ts and update the blog post retrieval to
exclude drafts (e.g., when calling getCollection('blog') filter out posts with
data.draft === true) so that otherPosts excludes drafts as well as unpublished
posts.


// Calculate scores for each post
const scoredPosts = otherPosts.map((post) => {
let score = 0;

// +2 points for each matching tag
const postTags = post.data.tags ?? [];
for (const tag of postTags) {
if (currentTags.includes(tag)) {
score += 2;
}
}

// +1 point for matching category
if (currentCategory && post.data.category === currentCategory) {
score += 1;
}

return { post, score };
});

// Sort by score (descending), then by pubDate (descending) for tie-breaking
scoredPosts.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
// Tie-breaker: newer posts first
return safeGetTime(b.post.data.pubDate) - safeGetTime(a.post.data.pubDate);
});

// Check if we have any posts with score > 0
const postsWithScore = scoredPosts.filter(item => item.score > 0);

if (postsWithScore.length > 0) {
// Return top 'limit' related posts
return postsWithScore.slice(0, limit).map(item => item.post);
}

// Fallback: return most recent posts if no related posts found
const recentPosts = [...otherPosts].sort(
(a, b) => safeGetTime(b.data.pubDate) - safeGetTime(a.data.pubDate),
);

return recentPosts.slice(0, limit);
}