Skip to content

Commit fe6a8cd

Browse files
authored
ENG-1655 - Refactor blog structure and add new features (#954)
* ENG-1655: Refactor blog structure and add new features - Updated blog directory path to use process.cwd() for better content management. - Introduced blog schema validation using Zod for frontmatter. - Added a new layout component for blog pages to enhance styling. - Refactored blog index and post pages to utilize dynamic imports and improve metadata handling. - Implemented functions to retrieve blog data and handle slugs more effectively. - Migrated example blog post to new content directory structure and created a new index file for blog updates. * . * Update CONTRIBUTING.md to reflect new blog post structure and requirements
1 parent 2a9f920 commit fe6a8cd

10 files changed

Lines changed: 415 additions & 160 deletions

File tree

CONTRIBUTING.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,25 @@ The Discourse Graphs website hosts documentation for plugins and general informa
2828

2929
### Blog Posts
3030

31-
Blog posts are located in `/apps/website/app/(home)/blog/posts/`
31+
Blog posts are located in `/apps/website/content/blog/`
3232

33-
1. **Create your post file**: Copy `EXAMPLE.md` as a starting template and rename it to your desired URL slug (e.g., `my-new-post.md`)
33+
1. **Create your post file**: Copy `EXAMPLE.mdx` as a starting template and rename it to your desired URL slug (for example, `my-new-post.mdx`)
3434

35-
2. **Required metadata**: Every blog post must start with YAML frontmatter (reference `EXAMPLE.md` for the exact format):
35+
2. **Required metadata**: Every blog post must start with YAML frontmatter (reference `EXAMPLE.mdx` for the exact format):
3636

3737
```yaml
3838
---
39-
title: "Your Post Title"
39+
title: "Your post title"
4040
date: "YYYY-MM-DD"
41-
author: "Author's name"
42-
published: true # Set to true to make the post visible
41+
author: "Author name"
42+
published: true
43+
tags:
44+
- release
45+
description: "Optional summary used for metadata."
4346
---
4447
```
4548

46-
3. **Content**: Write your content below the frontmatter using standard Markdown
49+
3. **Content**: Write your content below the frontmatter using Markdown or MDX. Blog posts render through Nextra, so standard markdown features and Nextra components are available.
4750

4851
### Plugin Documentation
4952

apps/website/app/(home)/blog/[slug]/page.tsx

Lines changed: 113 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,145 @@
1-
import fs from "fs/promises";
1+
import type { Metadata } from "next";
22
import { notFound } from "next/navigation";
3-
import { Metadata } from "next";
4-
import { getProcessedMarkdownFile } from "~/utils/getProcessedMarkdownFile";
5-
import { getFileMetadata } from "~/utils/getFileMetadata";
6-
import { BLOG_DIRECTORY } from "~/(home)/blog/blogDirectory";
3+
import { importPage } from "nextra/pages";
4+
import type { BlogData } from "../blogSchema";
5+
import { getAllBlogs, getBlogBySlug } from "../readBlogs";
76

87
type Params = {
98
params: Promise<{
109
slug: string;
1110
}>;
1211
};
1312

14-
const BlogPost = async ({ params }: Params) => {
13+
type ImportedPage = Awaited<ReturnType<typeof importPage>>;
14+
15+
const hasPrimaryHeading = (sourceCode: string): boolean =>
16+
/(^|\n)#\s+\S/m.test(sourceCode);
17+
18+
const loadBlogPage = async (slug: string): Promise<ImportedPage> =>
19+
importPage(["blog", slug]);
20+
21+
const getMetadataDescription = ({
22+
description,
23+
metadata,
24+
}: {
25+
description?: string;
26+
metadata: Metadata;
27+
}): string | undefined =>
28+
description ??
29+
(typeof metadata.description === "string" ? metadata.description : undefined);
30+
31+
const buildBlogPostMetadata = ({
32+
blog,
33+
pageMetadata,
34+
}: {
35+
blog: BlogData;
36+
pageMetadata: Metadata;
37+
}): Metadata => {
38+
const description = getMetadataDescription({
39+
description: blog.description,
40+
metadata: pageMetadata,
41+
});
42+
43+
return {
44+
...pageMetadata,
45+
title: blog.title,
46+
description,
47+
authors: [{ name: blog.author }],
48+
keywords: blog.tags.length ? blog.tags : pageMetadata.keywords,
49+
alternates: {
50+
...pageMetadata.alternates,
51+
canonical: `/blog/${blog.slug}`,
52+
},
53+
openGraph: {
54+
...pageMetadata.openGraph,
55+
type: "article",
56+
title: blog.title,
57+
description,
58+
publishedTime: blog.date,
59+
authors: [blog.author],
60+
tags: blog.tags.length ? blog.tags : undefined,
61+
url: `/blog/${blog.slug}`,
62+
},
63+
twitter: {
64+
...pageMetadata.twitter,
65+
title: blog.title,
66+
description,
67+
},
68+
};
69+
};
70+
71+
const BlogPost = async ({ params }: Params): Promise<React.ReactElement> => {
72+
const { slug } = await params;
73+
const blog = await getBlogBySlug(slug);
74+
75+
if (!blog) {
76+
notFound();
77+
}
78+
1579
try {
16-
const { slug } = await params;
17-
const { data, contentHtml } = await getProcessedMarkdownFile({
18-
slug,
19-
directory: BLOG_DIRECTORY,
20-
});
80+
const { default: MDXContent, sourceCode } = await loadBlogPage(slug);
81+
const showsPrimaryHeading = hasPrimaryHeading(sourceCode);
2182

2283
return (
2384
<div className="flex flex-1 flex-col items-center bg-gray-50 px-6 py-12">
24-
<div className="w-full max-w-4xl">
25-
<header className="mb-8 text-center">
26-
<h1 className="mb-4 text-5xl font-bold leading-tight text-primary">
27-
{data.title}
85+
<article className="w-full max-w-4xl rounded-xl bg-white p-8 shadow-md sm:p-10">
86+
{!showsPrimaryHeading && (
87+
<h1 className="text-5xl font-bold leading-tight text-primary">
88+
{blog.title}
2889
</h1>
90+
)}
91+
<div className={showsPrimaryHeading ? "mb-6" : "mb-8 mt-4"}>
2992
<p className="text-sm italic text-gray-500">
30-
By {data.author} {data.date}
93+
By {blog.author} | {blog.date}
3194
</p>
32-
</header>
33-
<article
34-
className="prose prose-lg lg:prose-xl prose-gray mx-auto leading-relaxed text-gray-700"
35-
dangerouslySetInnerHTML={{ __html: contentHtml }}
36-
/>
37-
</div>
95+
{blog.tags.length > 0 && (
96+
<ul className="mt-4 flex flex-wrap gap-2">
97+
{blog.tags.map((tag) => (
98+
<li
99+
key={tag}
100+
className="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium uppercase tracking-wide text-gray-600"
101+
>
102+
{tag}
103+
</li>
104+
))}
105+
</ul>
106+
)}
107+
</div>
108+
<div>
109+
<MDXContent />
110+
</div>
111+
</article>
38112
</div>
39113
);
40114
} catch (error) {
41115
console.error("Error rendering blog post:", error);
42-
return notFound();
116+
notFound();
43117
}
44118
};
45119

46-
export const generateStaticParams = async () => {
47-
try {
48-
const directoryExists = await fs
49-
.stat(BLOG_DIRECTORY)
50-
.then((stats) => stats.isDirectory())
51-
.catch(() => false);
52-
53-
if (!directoryExists) {
54-
console.log(
55-
"No app/blog/posts directory found. Returning empty params...",
56-
);
57-
return [];
58-
}
59-
60-
const files = await fs.readdir(BLOG_DIRECTORY);
61-
62-
const mdFiles = files.filter((filename) => filename.endsWith(".md"));
63-
64-
const results = await Promise.allSettled(
65-
mdFiles.map(async (filename) => {
66-
try {
67-
const { published } = await getFileMetadata({
68-
filename,
69-
directory: BLOG_DIRECTORY,
70-
});
71-
return { filename, published };
72-
} catch (error) {
73-
console.error(`Skipping ${filename} due to metadata error:`, error);
74-
return { filename, published: false };
75-
}
76-
}),
77-
);
78-
79-
const publishedFiles = results
80-
.filter((result) => result.status === "fulfilled")
81-
.map((result) => result.value);
82-
83-
return publishedFiles
84-
.filter(({ published }) => published)
85-
.map(({ filename }) => ({
86-
slug: filename.replace(/\.md$/, ""),
87-
}));
88-
} catch (error) {
89-
console.error("Error generating static params:", error);
90-
return [];
91-
}
92-
};
120+
export const generateStaticParams = async (): Promise<
121+
Array<{ slug: string }>
122+
> => (await getAllBlogs()).map(({ slug }) => ({ slug }));
93123

94124
export const generateMetadata = async ({
95125
params,
96126
}: Params): Promise<Metadata> => {
97127
try {
98128
const { slug } = await params;
99-
const { data } = await getProcessedMarkdownFile({
100-
slug,
101-
directory: BLOG_DIRECTORY,
102-
});
129+
const blog = await getBlogBySlug(slug);
103130

104-
return {
105-
title: data.title,
106-
authors: [{ name: data.author }],
107-
};
131+
if (!blog) {
132+
return {
133+
title: "Blog Post",
134+
};
135+
}
136+
137+
const { metadata } = await loadBlogPage(slug);
138+
139+
return buildBlogPostMetadata({
140+
blog,
141+
pageMetadata: metadata,
142+
});
108143
} catch (error) {
109144
console.error("Error generating metadata:", error);
110145
return {
Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
11
import path from "node:path";
2-
import { fileURLToPath } from "node:url";
32

4-
const BLOG_MODULE_PATH = fileURLToPath(import.meta.url);
5-
6-
export const BLOG_DIRECTORY = path.join(
7-
path.dirname(BLOG_MODULE_PATH),
8-
"posts",
9-
);
3+
export const BLOG_DIRECTORY = path.join(process.cwd(), "content", "blog");
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { z } from "zod";
2+
3+
// eslint-disable-next-line @typescript-eslint/naming-convention
4+
const BlogTagsSchema = z
5+
.union([z.array(z.string()), z.string()])
6+
.transform((value) => (Array.isArray(value) ? value : [value]));
7+
8+
// eslint-disable-next-line @typescript-eslint/naming-convention
9+
export const BlogFrontmatterSchema = z.object({
10+
title: z.string(),
11+
published: z.boolean().default(false),
12+
date: z.string(),
13+
author: z.string(),
14+
description: z.string().optional(),
15+
tags: BlogTagsSchema.optional().default([]),
16+
});
17+
18+
export type BlogFrontmatter = z.infer<typeof BlogFrontmatterSchema>;
19+
export type BlogData = BlogFrontmatter & {
20+
slug: string;
21+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import "~/(nextra)/nextra-css.css";
2+
import "nextra-theme-docs/style-prefixed.css";
3+
4+
type BlogLayoutProps = {
5+
children: React.ReactNode;
6+
};
7+
8+
const BlogLayout = ({ children }: BlogLayoutProps): React.ReactElement => (
9+
<div className="nextra-reset flex flex-1 flex-col">{children}</div>
10+
);
11+
12+
export default BlogLayout;

apps/website/app/(home)/blog/page.tsx

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,35 @@
1+
import type { Metadata } from "next";
12
import Link from "next/link";
3+
import { importPage } from "nextra/pages";
24
import { getAllBlogs } from "./readBlogs";
35

4-
export default async function BlogIndex() {
6+
type ImportedPage = Awaited<ReturnType<typeof importPage>>;
7+
8+
const hasPrimaryHeading = (sourceCode: string): boolean =>
9+
/(^|\n)#\s+\S/m.test(sourceCode);
10+
11+
const loadBlogIndex = async (): Promise<ImportedPage> => importPage(["blog"]);
12+
13+
const BlogIndex = async (): Promise<React.ReactElement> => {
514
const blogs = await getAllBlogs();
15+
const { default: MDXContent, metadata, sourceCode } = await loadBlogIndex();
16+
const showsPrimaryHeading = hasPrimaryHeading(sourceCode);
17+
618
return (
719
<div className="flex-1 bg-gray-50">
820
<div className="mx-auto max-w-6xl space-y-12 px-6 py-12">
9-
<div className="rounded-xl bg-white p-8 shadow-md">
10-
<div className="mb-8">
11-
<h1 className="text-4xl font-bold text-primary">All Updates</h1>
21+
<article className="rounded-xl bg-white p-8 shadow-md">
22+
<div className="space-y-4">
23+
{!showsPrimaryHeading && (
24+
<h1 className="text-4xl font-bold text-primary">
25+
{metadata.title}
26+
</h1>
27+
)}
28+
<div className="text-gray-600">
29+
<MDXContent />
30+
</div>
1231
</div>
13-
<div>
32+
<div className="mt-8">
1433
<ul className="space-y-6">
1534
{blogs.length === 0 ? (
1635
<p className="text-left text-lg text-gray-600">
@@ -41,8 +60,24 @@ export default async function BlogIndex() {
4160
)}
4261
</ul>
4362
</div>
44-
</div>
63+
</article>
4564
</div>
4665
</div>
4766
);
48-
}
67+
};
68+
69+
export const generateMetadata = async (): Promise<Metadata> => {
70+
try {
71+
const { metadata } = await loadBlogIndex();
72+
73+
return metadata;
74+
} catch (error) {
75+
console.error("Error generating blog index metadata:", error);
76+
77+
return {
78+
title: "All Updates",
79+
};
80+
}
81+
};
82+
83+
export default BlogIndex;

0 commit comments

Comments
 (0)