feat: add tag pages, related posts, and re-enable AI service links

- Add /tags/[tag] routes with dynamic tag archive pages
- Add related posts component to blog post footers (up to 3 by shared tags)
- Tag links in post footers now navigate to tag archive pages
- Re-enable Open in AI links using GitHub raw URLs (bypasses edge issues)
- Add gitHubRepo config in siteConfig.ts for raw URL construction
- Add by_tags index and getAllTags/getPostsByTag/getRelatedPosts queries
- Update sitemap to include dynamically generated tag pages
- Add mobile responsive styling for all new components
- Update docs with git push requirement for AI links to work
This commit is contained in:
Wayne Sutton
2025-12-24 13:49:00 -08:00
parent dc5f9eff4c
commit 4bc26c7a33
15 changed files with 866 additions and 64 deletions

View File

@@ -23,13 +23,14 @@ http.route({
handler: rssFullFeed,
});
// Sitemap.xml endpoint for search engines (includes posts and pages)
// Sitemap.xml endpoint for search engines (includes posts, pages, and tag pages)
http.route({
path: "/sitemap.xml",
method: "GET",
handler: httpAction(async (ctx) => {
const posts = await ctx.runQuery(api.posts.getAllPosts);
const pages = await ctx.runQuery(api.pages.getAllPages);
const tags = await ctx.runQuery(api.posts.getAllTags);
const urls = [
// Homepage
@@ -53,6 +54,14 @@ http.route({
<loc>${SITE_URL}/${page.slug}</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>`,
),
// All tag pages
...tags.map(
(tagInfo) => ` <url>
<loc>${SITE_URL}/tags/${encodeURIComponent(tagInfo.tag.toLowerCase())}</loc>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>`,
),
];

View File

@@ -369,3 +369,159 @@ export const getViewCount = query({
return viewCount?.count ?? 0;
},
});
// Get all unique tags from published posts
export const getAllTags = query({
args: {},
returns: v.array(
v.object({
tag: v.string(),
count: v.number(),
}),
),
handler: async (ctx) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Count occurrences of each tag
const tagCounts = new Map<string, number>();
for (const post of posts) {
for (const tag of post.tags) {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
}
}
// Convert to array and sort by count (descending), then alphabetically
return Array.from(tagCounts.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return a.tag.localeCompare(b.tag);
});
},
});
// Get posts filtered by a specific tag
export const getPostsByTag = query({
args: {
tag: v.string(),
},
returns: v.array(
v.object({
_id: v.id("posts"),
_creationTime: v.number(),
slug: v.string(),
title: v.string(),
description: v.string(),
date: v.string(),
published: v.boolean(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
}),
),
handler: async (ctx, args) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Filter posts that have the specified tag
const filteredPosts = posts.filter((post) =>
post.tags.some((t) => t.toLowerCase() === args.tag.toLowerCase()),
);
// Sort by date descending
const sortedPosts = filteredPosts.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
// Return without content for list view
return sortedPosts.map((post) => ({
_id: post._id,
_creationTime: post._creationTime,
slug: post.slug,
title: post.title,
description: post.description,
date: post.date,
published: post.published,
tags: post.tags,
readTime: post.readTime,
image: post.image,
excerpt: post.excerpt,
featured: post.featured,
featuredOrder: post.featuredOrder,
authorName: post.authorName,
authorImage: post.authorImage,
}));
},
});
// Get related posts that share tags with the current post
export const getRelatedPosts = query({
args: {
currentSlug: v.string(),
tags: v.array(v.string()),
limit: v.optional(v.number()),
},
returns: v.array(
v.object({
_id: v.id("posts"),
slug: v.string(),
title: v.string(),
description: v.string(),
date: v.string(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
sharedTags: v.number(),
}),
),
handler: async (ctx, args) => {
const maxResults = args.limit ?? 3;
// Skip if no tags provided
if (args.tags.length === 0) {
return [];
}
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Find posts that share tags, excluding current post
const relatedPosts = posts
.filter((post) => post.slug !== args.currentSlug)
.map((post) => {
const sharedTags = post.tags.filter((tag) =>
args.tags.some((t) => t.toLowerCase() === tag.toLowerCase()),
).length;
return {
_id: post._id,
slug: post.slug,
title: post.title,
description: post.description,
date: post.date,
tags: post.tags,
readTime: post.readTime,
sharedTags,
};
})
.filter((post) => post.sharedTags > 0)
.sort((a, b) => {
// Sort by shared tags count first, then by date
if (b.sharedTags !== a.sharedTags) return b.sharedTags - a.sharedTags;
return new Date(b.date).getTime() - new Date(a.date).getTime();
})
.slice(0, maxResults);
return relatedPosts;
},
});