diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2a89e1..cc6a5e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- SocialIcon component
- Share post component
- Lint staged files
+- Tag generation and routing
### Changed
diff --git a/README.md b/README.md
index 37d1b37..f94105f 100644
--- a/README.md
+++ b/README.md
@@ -174,13 +174,15 @@ Note: DO NOT overdo it. You can easily make images look bad with lossy compressi
- [x] Back to top button
- [x] Social icons component
- [x] Social sharing buttons
-- [ ] Tags, categories
+- [x] Tags
- [ ] newsletter integration (form, api route, keys, welcome page, previous issues)
- [ ] Other analytics providers (fathom, simplelytics, plausible, etc)
- [ ] Post series page
- [ ] prev/next post links
- [ ] related posts
- [ ] Layouts/templates system
+- [ ] Notion data source
+- [ ] Sanity data source
- [ ] hero title and subtitle text HTML support(?)
- [ ] Design improvements (whitespace, layout, etc.)
- [ ] error, and loading pages
diff --git a/app/(site)/page.tsx b/app/(site)/page.tsx
index bef27be..e00680b 100644
--- a/app/(site)/page.tsx
+++ b/app/(site)/page.tsx
@@ -1,7 +1,8 @@
import Image from "next/image";
import Link from "next/link";
+import { notFound } from "next/navigation";
import { allPages, allPosts } from "@/.contentlayer/generated";
-import { compareDesc, format, parseISO } from "date-fns";
+import { compareDesc } from "date-fns";
import { ArrowRight } from "lucide-react";
import { defaultAuthor } from "@/lib/metadata";
@@ -16,13 +17,13 @@ import { Mdx } from "@/components/mdx-components";
import PostPreview from "@/components/post-preview";
async function getAboutPage() {
- const page = allPages.find((page) => page.slug === "about");
+ const aboutPage = allPages.find((page) => page.slug === "about");
- if (!page) {
+ if (!aboutPage) {
null;
}
- return page;
+ return aboutPage;
}
export default async function Home() {
diff --git a/app/(site)/posts/[slug]/page.tsx b/app/(site)/posts/[slug]/page.tsx
index 1a54640..a90f2a1 100644
--- a/app/(site)/posts/[slug]/page.tsx
+++ b/app/(site)/posts/[slug]/page.tsx
@@ -161,9 +161,11 @@ export default async function PostPage({ params }: PostProps) {
{post.tags && (
- {post.tags.map((tag: any) => (
- -
- {tag}
+ {post.tags.map((tag: string) => (
+
-
+
+ {tag}
+
))}
diff --git a/app/(site)/posts/page.tsx b/app/(site)/posts/page.tsx
index 258bc83..1c8dabe 100644
--- a/app/(site)/posts/page.tsx
+++ b/app/(site)/posts/page.tsx
@@ -18,6 +18,7 @@ export default function Blog() {
.sort((a, b) =>
compareDesc(new Date(a.lastUpdatedDate || a.publishedDate), new Date(b.lastUpdatedDate || b.publishedDate))
);
+
return (
diff --git a/app/(site)/tags/[slug]/page.tsx b/app/(site)/tags/[slug]/page.tsx
new file mode 100644
index 0000000..20f957a
--- /dev/null
+++ b/app/(site)/tags/[slug]/page.tsx
@@ -0,0 +1,55 @@
+import { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { allPosts, Post } from "@/.contentlayer/generated";
+import { compareDesc } from "date-fns";
+
+import PostPreview from "@/components/post-preview";
+
+// Get sorted articles from the contentlayer
+async function getSortedArticles(): Promise
{
+ let articles = await allPosts;
+
+ articles = articles.filter((article: Post) => article.status === "published");
+
+ return articles.sort((a: Post, b: Post) => {
+ if (a.publishedDate && b.publishedDate) {
+ return new Date(b.publishedDate).getTime() - new Date(a.publishedDate).getTime();
+ }
+ return 0;
+ });
+}
+
+// Dynamic metadata for the page
+export async function generateMetadata({ params }: { params: { slug: string } }): Promise {
+ return {
+ title: `All posts in ${params.slug}`,
+ description: `All posts in ${params.slug}`,
+ };
+}
+
+export default async function TagPage({ params }: { params: { slug: string } }) {
+ const tag = params.slug;
+
+ const posts = allPosts
+ .filter((post) => post.status === "published")
+ .filter((post) => post.tags?.includes(tag))
+ .sort((a, b) => compareDesc(new Date(a.publishedDate), new Date(b.publishedDate)));
+
+ if (!posts) {
+ notFound();
+ }
+
+ return (
+
+
+
All posts in {tag}
+
+
+ {posts.map((post) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/(site)/tags/page.tsx b/app/(site)/tags/page.tsx
new file mode 100644
index 0000000..e63a00d
--- /dev/null
+++ b/app/(site)/tags/page.tsx
@@ -0,0 +1,54 @@
+import { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { allPosts } from "@/.contentlayer/generated";
+
+import siteMetadata from "@/lib/metadata";
+import { getTagsWithCount } from "@/lib/utils";
+import { Badge } from "@/components/ui/badge";
+
+export async function generateMetadata(): Promise {
+ return {
+ title: "Tags",
+ description: `All tags in ${siteMetadata.title}`,
+ };
+}
+
+export default function TagsPage() {
+ const posts = allPosts.filter((post) => post.status === "published");
+
+ const tags = getTagsWithCount(posts);
+
+ if (!tags || Object.keys(tags).length === 0) {
+ notFound();
+ }
+
+ return (
+
+
+
All tags
+
+
+
+ {Object.keys(tags).map((tag) => {
+ return (
+ -
+
+
+
+ {tag} · ({tags[tag]})
+
+
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/components/cta.tsx b/components/cta.tsx
index fa2e68d..3a50492 100644
--- a/components/cta.tsx
+++ b/components/cta.tsx
@@ -43,7 +43,6 @@ const CTA = ({ title, description, buttonText }: CTAProps) => {
),
});
- console.log(values);
}
return (
diff --git a/components/post-preview.tsx b/components/post-preview.tsx
index 20b6bf0..e97782d 100644
--- a/components/post-preview.tsx
+++ b/components/post-preview.tsx
@@ -14,7 +14,7 @@ const PostPreview = ({ post }: PostPreviewProps) => {
return (
{
{post?.tags && (
- {post.tags.map((tag: any) => (
- -
+ {post.tags.map((tag: string) => (
+
-
({
name: "Post",
filePathPattern: `posts/**/*.mdx`,
@@ -26,7 +43,8 @@ export const Post = defineDocumentType(() => ({
},
tags: {
type: "list",
- of: { type: "string" },
+ of: { type: "string", options: tagOptions },
+ required: false,
},
series: {
type: "nested",
@@ -39,6 +57,19 @@ export const Post = defineDocumentType(() => ({
},
},
computedFields: {
+ tagSlugs: {
+ type: "list",
+ resolve: async (doc) => {
+ if (doc.tags) {
+ // make a new array of tags to use them in computedFields https://github.com/contentlayerdev/contentlayer/issues/149
+ const tags = [...(doc?.tags ?? ([] as string[]))];
+ const slugger = new GithubSlugger();
+
+ return tags.map((tag) => slugger.slug(tag));
+ }
+ return null;
+ },
+ },
readTimeMinutes: {
type: "number",
resolve: (doc) => calculateReadingTime(doc.body.raw),
diff --git a/lib/utils.ts b/lib/utils.ts
index 66cf101..2989d40 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -1,3 +1,4 @@
+import { Post } from "@/.contentlayer/generated";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
@@ -14,3 +15,15 @@ export const calculateReadingTime = (text: string): number => {
return readTime;
};
+
+export const getTagsWithCount = (posts: Post[]) =>
+ posts.reduce((acc: any, post: Post) => {
+ post.tags?.forEach((tag: any) => {
+ if (acc[tag]) {
+ acc[tag] += 1;
+ } else {
+ acc[tag] = 1;
+ }
+ });
+ return acc;
+ }, {});