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
15 changes: 11 additions & 4 deletions apps/website/app/(home)/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import fs from "fs/promises";
import path from "path";
import { notFound } from "next/navigation";
import { Metadata } from "next";
import { getBlog } from "../readBlogs";
import { getMarkdownPage } from "~/utils/getMarkdownFile";
import { BLOG_PATH } from "~/data/constants";

type Params = {
params: Promise<{
Expand All @@ -13,7 +14,10 @@ type Params = {
export default async function BlogPost({ params }: Params) {
try {
const { slug } = await params;
const { data, contentHtml } = await getBlog(slug);
const { data, contentHtml } = await getMarkdownPage({
slug,
directory: BLOG_PATH,
});

return (
<div className="flex flex-1 flex-col items-center bg-gray-50 px-6 py-12">
Expand Down Expand Up @@ -41,7 +45,7 @@ export default async function BlogPost({ params }: Params) {

export async function generateStaticParams() {
try {
const blogPath = path.join(process.cwd(), "app/blog/posts");
const blogPath = path.resolve(process.cwd(), BLOG_PATH);
// 1) Check if the directory exists
const directoryExists = await fs
.stat(blogPath)
Expand Down Expand Up @@ -74,7 +78,10 @@ export async function generateStaticParams() {
export async function generateMetadata({ params }: Params): Promise<Metadata> {
try {
const { slug } = await params;
const { data } = await getBlog(slug);
const { data } = await getMarkdownPage({
slug,
directory: BLOG_PATH,
});

return {
title: data.title,
Expand Down
50 changes: 9 additions & 41 deletions apps/website/app/(home)/blog/readBlogs.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { remark } from "remark";
import html from "remark-html";
import { notFound } from "next/navigation";
import path from "path";
import fs from "fs/promises";
import matter from "gray-matter";
import { BlogSchema, type Blog, BlogFrontmatter } from "./schema";
import { BLOG_PATH } from "~/data/constants";
import { PageSchema, type PageData } from "~/types/schema";

const BLOG_DIRECTORY = path.join(process.cwd(), "app/blog/posts");
const BLOG_DIRECTORY = path.join(process.cwd(), BLOG_PATH);

async function validateBlogDirectory(): Promise<boolean> {
try {
Expand All @@ -18,12 +16,12 @@ async function validateBlogDirectory(): Promise<boolean> {
}
}

async function processBlogFile(filename: string): Promise<Blog | null> {
async function processBlogFile(filename: string): Promise<PageData | null> {
try {
const filePath = path.join(BLOG_DIRECTORY, filename);
const filePath = path.resolve(BLOG_DIRECTORY, filename);
const fileContent = await fs.readFile(filePath, "utf-8");
const { data } = matter(fileContent);
const validatedData = BlogSchema.parse(data);
const validatedData = PageSchema.parse(data);

return {
slug: filename.replace(/\.md$/, ""),
Expand All @@ -35,12 +33,7 @@ async function processBlogFile(filename: string): Promise<Blog | null> {
}
}

async function getMarkdownContent(content: string): Promise<string> {
const processedContent = await remark().use(html).process(content);
return processedContent.toString();
}

export async function getAllBlogs(): Promise<Blog[]> {
export async function getAllBlogs(): Promise<PageData[]> {
try {
const directoryExists = await validateBlogDirectory();
if (!directoryExists) return [];
Expand All @@ -49,40 +42,15 @@ export async function getAllBlogs(): Promise<Blog[]> {
const blogs = await Promise.all(
files.filter((filename) => filename.endsWith(".md")).map(processBlogFile),
);
const validBlogs = blogs.filter(Boolean) as Blog[];
const validBlogs = blogs.filter((blog): blog is PageData => blog !== null);
return validBlogs.filter((blog) => blog.published);
} catch (error) {
console.error("Error reading blog directory:", error);
return [];
}
}

export async function getBlog(
slug: string,
): Promise<{ data: BlogFrontmatter; contentHtml: string }> {
try {
const filePath = path.join(BLOG_DIRECTORY, `${slug}.md`);
await fs.access(filePath);

const fileContent = await fs.readFile(filePath, "utf-8");
const { data: rawData, content } = matter(fileContent);
const data = BlogSchema.parse(rawData);

if (!data.published) {
console.log(`Post ${slug} is not published`);
return notFound();
}

const contentHtml = await getMarkdownContent(content);

return { data, contentHtml };
} catch (error) {
console.error("Error loading blog post:", error);
return notFound();
}
}

export async function getLatestBlogs(): Promise<Blog[]> {
export async function getLatestBlogs(): Promise<PageData[]> {
const blogs = await getAllBlogs();
return blogs
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
Expand Down
1 change: 1 addition & 0 deletions apps/website/app/data/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const BLOG_PATH = "app/(home)/blog/posts";
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { z } from "zod";

export const BlogSchema = z.object({
export const PageSchema = z.object({
title: z.string(),
published: z.boolean().default(false),
date: z.string(),
author: z.string(),
published: z.boolean().default(false),
});

export type BlogFrontmatter = z.infer<typeof BlogSchema>;
export type PageFrontmatter = z.infer<typeof PageSchema>;

export type Blog = BlogFrontmatter & {
export type PageData = PageFrontmatter & {
slug: string;
};
25 changes: 25 additions & 0 deletions apps/website/app/utils/getFileContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import path from "path";
import fs from "fs/promises";

type Props = {
filename: string;
directory: string;
};

export const getFileContent = async ({
filename,
directory,
}: Props): Promise<string> => {
try {
const safeFilename = path.basename(filename);
const filePath = path.join(directory, safeFilename);
const fileContent = await fs.readFile(filePath, "utf-8");
return fileContent;
} catch (error) {
throw error instanceof Error
? new Error(`Failed to read file ${filename}: ${error.message}`, {
cause: error,
})
: new Error(`Failed to read file ${filename}: Unknown error`);
}
};
43 changes: 43 additions & 0 deletions apps/website/app/utils/getHtmlFromMarkdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import { toString } from "mdast-util-to-string";
import { visit } from "unist-util-visit";
import type { Root } from "mdast";

function remarkHeadingId() {
return (tree: Root) => {
visit(tree, "heading", (node) => {
const text = toString(node);
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");

node.data = {
hName: `h${node.depth}`,
hProperties: { id },
};
});
};
}

export async function getHtmlFromMarkdown(markdown: string): Promise<string> {
if (!markdown) {
throw new Error('Markdown content is required');
}

try {
const htmlString = await unified()
.use(remarkParse)
.use(remarkHeadingId)
.use(remarkRehype)
.use(rehypeStringify)
.process(markdown);
return htmlString.toString();
} catch (error) {
console.error('Error processing markdown:', error);
throw new Error('Failed to process markdown content');
}
}
50 changes: 50 additions & 0 deletions apps/website/app/utils/getMarkdownFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { getHtmlFromMarkdown } from "~/utils/getHtmlFromMarkdown";
import { getFileContent } from "~/utils/getFileContent";
import { notFound } from "next/navigation";
import { PageFrontmatter, PageSchema } from "~/types/schema";
import matter from "gray-matter";

type Props = {
slug: string;
directory: string;
};

type ProcessedMarkdownPage = {
data: PageFrontmatter;
contentHtml: string;
};

export const getMarkdownPage = async ({
slug,
directory,
}: Props): Promise<ProcessedMarkdownPage> => {
try {
if (!slug || !directory) {
throw new Error('Both slug and directory are required');
}

// Prevent directory traversal
if (slug.includes('..') || directory.includes('..')) {
throw new Error('Invalid path');
}

const fileContent = await getFileContent({
filename: `${slug}.md`,
directory,
});
const { data: rawData, content } = matter(fileContent);
const data = PageSchema.parse(rawData);

if (!data.published) {
console.log(`Post ${slug} is not published`);
return notFound();
}

const contentHtml = await getHtmlFromMarkdown(content);

return { data, contentHtml };
} catch (error) {
console.error("Error loading blog post:", error);
return notFound();
}
};
6 changes: 4 additions & 2 deletions apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@
"lint": "next lint"
},
"dependencies": {
"@repo/types": "*",
"@repo/ui": "*",
"gray-matter": "^4.0.3",
"next": "^15.0.3",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106",
"rehype-stringify": "^10.0.1",
"remark": "^15.0.1",
"remark-html": "^16.0.1",
"zod": "^3.24.1",
"remark-rehype": "^11.1.1",
"resend": "^4.0.1",
"@repo/types": "*"
"zod": "^3.24.1"
},
"devDependencies": {
"@repo/eslint-config": "*",
Expand Down
34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.