Skip to content

Add tag taxonomy to blog example #12

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 12 commits into from
16 changes: 11 additions & 5 deletions components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { FC } from 'react'
import React from 'react'
import { FC } from "react";
import React from "react";

export const Button: FC<{ title: string }> = ({ title }) => (
<div
style={{ padding: 10, backgroundColor: '#333', color: '#fff', display: 'inline-block', borderRadius: 4 }}
onClick={() => alert('Hi')}
style={{
padding: 10,
backgroundColor: "#333",
color: "#fff",
display: "inline-block",
borderRadius: 4,
}}
onClick={() => alert("Hi")}
>
{title}
</div>
)
);
16 changes: 11 additions & 5 deletions components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import Link from 'next/link'
import Link from "next/link";

function Icon() {
return (
<svg width="22" height="24" viewBox="0 0 22 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="22"
height="24"
viewBox="0 0 22 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
Expand All @@ -12,7 +18,7 @@ function Icon() {
strokeWidth="0.5"
></path>
</svg>
)
);
}

function Logo() {
Expand All @@ -25,13 +31,13 @@ function Logo() {
<span className="font-bold">Contentlayer</span>
</a>
</Link>
)
);
}

export function Header() {
return (
<header className="p-8 flex justify-center">
<Logo />
</header>
)
);
}
9 changes: 9 additions & 0 deletions components/Tag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Link from "next/link";

export function Tag({ tag }: { tag: string }) {
return (
<span className="text-xs bg-gray-200 rounded py-0.5 px-1.5 hover:bg-gray-300">
<Link href={`/tags/${tag}`}>{tag}</Link>
</span>
);
}
11 changes: 11 additions & 0 deletions components/Tags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Tag } from "./Tag";

export function Tags({ tags }: { tags: string[] }) {
return (
<div className="inline-flex space-x-1">
{tags.map((tag) => (
<Tag key={tag} tag={tag} />
))}
</div>
);
}
8 changes: 8 additions & 0 deletions contentlayer.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ const Post = defineDocumentType(() => ({
description: 'The date of the post',
required: true,
},
tags: {
type: 'list',
of: {
type: 'string'
},
description: 'Optional tags associated with the post',
default: []
}
},
computedFields: {
url: {
Expand Down
44 changes: 44 additions & 0 deletions lib/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { allPosts, Post } from "contentlayer/generated";
import { unique } from "typescript-array-utils";
import { compareDesc, format, parseISO } from "date-fns";

// All tags used by blog posts
const allTags: string[] = unique(allPosts.flatMap((post) => post.tags));

// All tags except "this" one
const allOtherTags = (tag: string): string[] =>
allTags.filter((t) => !t.includes(tag));

// Fetch post by slug (use only when all slugs are known in advance)
const getPostBySlug = (slug: string): Post =>
allPosts.find((post) => post._raw.flattenedPath === slug);

// All posts marked with a specific tag
const postsWithTag = (tag: string): Post[] | undefined =>
allPosts.filter((post) => post.tags.includes(tag));

// Paths for all posts
const allPostPaths: string[] = allPosts.map((post) => post.url);

// All posts sorted by date
const allPostsByDate: Post[] = allPosts.sort((a, b) => {
return compareDesc(new Date(a.date), new Date(b.date));
});

// Paths for all tags
const allTagPaths: string[] = allTags.map((tag) => `/tags/${tag}`);

// Formatters
const formatDate = (date: string): string =>
format(parseISO(date), "LLLL d, yyyy");

export {
allTags,
allOtherTags,
getPostBySlug,
postsWithTag,
allPostPaths,
allPostsByDate,
allTagPaths,
formatDate,
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"next": "12.1.6",
"next-contentlayer": "latest",
"react": "18.1.0",
"react-dom": "18.1.0"
"react-dom": "18.1.0",
"typescript-array-utils": "^0.1.4"
},
"devDependencies": {
"@types/react": "18.0.9",
Expand Down
8 changes: 4 additions & 4 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Head from 'next/head'
import Head from "next/head";

import '../styles/globals.css'
import "../styles/globals.css";

import { Header } from '../components/Header'
import { Header } from "../components/Header";

// This default export is required in a new `pages/_app.js` file.
export default function MyApp({ Component, pageProps }) {
Expand All @@ -19,5 +19,5 @@ export default function MyApp({ Component, pageProps }) {
<Component {...pageProps} />
</div>
</>
)
);
}
28 changes: 17 additions & 11 deletions pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
import Link from "next/link";
import { compareDesc, format, parseISO } from "date-fns";
import { allPosts, Post } from "contentlayer/generated";
import { Post } from "contentlayer/generated";
import { Tags } from "components/Tags";
import { allPostsByDate, formatDate } from "lib/content";

export async function getStaticProps() {
const posts: Post[] = allPosts.sort((a, b) => {
return compareDesc(new Date(a.date), new Date(b.date));
});
return { props: { posts } };
return { props: { posts: allPostsByDate } };
}

function PostCard(post: Post) {
export function PostCard(post: Post) {
const formattedDate: string = formatDate(post.date);

return (
<div className="mb-8">
<div>
<h2 className="text-xl">
<Link href={post.url}>
<a className="text-blue-700 hover:text-blue-900">{post.title}</a>
</Link>
</h2>
<time dateTime={post.date} className="block text-xs text-gray-600 mb-2">
{format(parseISO(post.date), "LLLL d, yyyy")}
</time>
<div className="flex justify-between items-center mb-4">
<div>
<time dateTime={post.date} className="text-xs text-gray-600">
{formattedDate}
</time>
</div>

<Tags tags={post.tags} />
</div>
<div
className="text-sm"
dangerouslySetInnerHTML={{ __html: post.body.html }}
Expand Down
19 changes: 11 additions & 8 deletions pages/posts/[slug].tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
import Head from "next/head";
import { format, parseISO } from "date-fns";
import { allPosts, Post } from "contentlayer/generated";
import { Post } from "contentlayer/generated";
import { Tags } from "components/Tags";
import { allPostPaths, formatDate, getPostBySlug } from "lib/content";

export async function getStaticPaths() {
const paths: string[] = allPosts.map((post) => post.url);
return {
paths,
paths: allPostPaths,
fallback: false,
};
}

export async function getStaticProps({ params }) {
const post: Post = allPosts.find(
(post) => post._raw.flattenedPath === params.slug
);
const post: Post = getPostBySlug(params.slug);
return {
props: {
post,
Expand All @@ -22,6 +20,8 @@ export async function getStaticProps({ params }) {
}

const PostLayout = ({ post }: { post: Post }) => {
const formattedDate: string = formatDate(post.date);

return (
<>
<Head>
Expand All @@ -30,9 +30,12 @@ const PostLayout = ({ post }: { post: Post }) => {
<article className="max-w-xl mx-auto py-8">
<div className="text-center mb-8">
<time dateTime={post.date} className="text-xs text-gray-600 mb-1">
{format(parseISO(post.date), "LLLL d, yyyy")}
{formattedDate}
</time>
<h1>{post.title}</h1>
<div className="mt-3">
<Tags tags={post.tags} />
</div>
</div>
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
</article>
Expand Down
54 changes: 54 additions & 0 deletions pages/tags/[tag].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Tags } from "components/Tags";
import { Post } from "contentlayer/generated";
import { allOtherTags, allTagPaths, postsWithTag } from "lib/content";
import Head from "next/head";
import { PostCard } from "pages";

export async function getStaticPaths() {
return {
paths: allTagPaths,
fallback: false,
};
}

export async function getStaticProps({ params }) {
const posts: Post[] | undefined = postsWithTag(params.tag);

return {
props: {
posts,
tag: params.tag,
},
};
}

const TagLayout = ({ posts, tag }: { posts: Post[]; tag: string }) => {
const otherTags: string[] = allOtherTags(tag);

return (
<>
<Head>
<title>Posts with the tag {tag}</title>
</Head>
<div className="max-w-xl mx-auto py-8">
<h1 className="text-3xl font-light mb-8 text-center">
Posts with the tag <strong>{tag}</strong>
</h1>

<div>
{posts.map((post, idx) => (
<PostCard key={idx} {...post} />
))}
</div>

<div className="flex items-center justify-between mt-12">
<h2 className="text-xl font-light">Other available tags</h2>

<Tags tags={otherTags} />
</div>
</div>
</>
);
};

export default TagLayout;
12 changes: 12 additions & 0 deletions pages/tags/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Tags } from "components/Tags";
import { allTags } from "lib/content";

export default function AllTags() {
return (
<div className="max-w-xl mx-auto py-8 text-center">
<h1 className="text-3xl font-light mb-8">All tags used on the blog</h1>

<Tags tags={allTags} />
</div>
);
}
1 change: 1 addition & 0 deletions posts/change-me.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: Change me!
date: 2022-03-11
tags: [cache, how-to]
---

When you change a source file, Contentlayer automatically updates the content cache, which prompts Next.js to reload the content on screen.
1 change: 1 addition & 0 deletions posts/click-me.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: Click me!
date: 2022-02-28
tags: [html, how-to]
---

Blog posts have their own pages. The content source is a markdown file, parsed to HTML by Contentlayer.
1 change: 1 addition & 0 deletions posts/what-is-contentlayer.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: What is Contentlayer?
date: 2022-02-22
tags: [content, technical]
---

**Contentlayer makes working with content easy.** It is a content preprocessor that validates and transforms your content into type-safe JSON you can easily import into your application.