Skip to content
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

Fix Possible News Problems and Add Tests #4

Merged
merged 7 commits into from
Feb 12, 2024
Merged
21 changes: 21 additions & 0 deletions app/[locale]/news/[slug]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Link from "next/link";
import { useTranslations } from "next-intl";

export default function SlugNotFound() {
const t = useTranslations("news.notFound");

return (
<div className="pt-10 pb-24 sm:pt-12 sm:pb-32">
<div className="mx-auto max-w-3xl text-base leading-7">
<h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl mb-12 text-center font-display">
{t("title")}
</h1>
<div className="prose dark:prose-invert prose-headings:font-display prose-a:text-primary prose-pre:bg-muted prose-pre:py-3 prose-pre:px-4 prose-pre:rounded prose-img:rounded-md max-w-none">
{t.rich("description", {
newsLink: (chunks) => <Link href="/news">{chunks}</Link>,
})}
</div>
</div>
</div>
);
}
49 changes: 31 additions & 18 deletions app/[locale]/news/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Date from "@/components/Date";

import { getPostData } from "@/lib/news";
import { checkIfSlugIsValid, getPostData } from "@/lib/news";
import { notFound } from "next/navigation";

export type Params = {
slug: string;
Expand All @@ -17,32 +18,44 @@ export type PostData = {
};

export async function generateMetadata({ params }: Props) {
const postData: PostData = await getPostData(params.slug);
const slug = params.slug;

if (!(await checkIfSlugIsValid(slug))) {
return {
title: "Not Found",
};
}

const postData: PostData = await getPostData(slug);

return {
title: postData.title,
};
}

export default async function Post({ params }: Props) {
const postData: PostData = await getPostData(params.slug);
const slug = params.slug;

if (!(await checkIfSlugIsValid(slug))) {
notFound();
}

const postData: PostData = await getPostData(slug);

return (
<>
<div className="py-24 sm:py-32">
<div className="mx-auto max-w-3xl text-base leading-7">
<p className="text-base font-semibold leading-7 text-primary text-center uppercase font-display">
<Date dateString={postData.date} />
</p>
<h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl mb-12 text-center font-display">
{postData.title}
</h1>
<div
className="prose dark:prose-invert prose-headings:font-display prose-a:text-primary prose-pre:bg-muted prose-pre:py-3 prose-pre:px-4 prose-pre:rounded prose-img:rounded-md max-w-none"
dangerouslySetInnerHTML={{ __html: postData.contentHtml }}
/>
</div>
<div className="py-24 sm:py-32">
<div className="mx-auto max-w-3xl text-base leading-7">
<p className="text-base font-semibold leading-7 text-primary text-center uppercase font-display">
<Date dateString={postData.date} />
</p>
<h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl mb-12 text-center font-display">
{postData.title}
</h1>
<div
className="prose dark:prose-invert prose-headings:font-display prose-a:text-primary prose-pre:bg-muted prose-pre:py-3 prose-pre:px-4 prose-pre:rounded prose-img:rounded-md max-w-none"
dangerouslySetInnerHTML={{ __html: postData.contentHtml }}
/>
</div>
</>
</div>
);
}
108 changes: 108 additions & 0 deletions lib/__tests__/news.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import * as newsLib from "../news";

// Mocks
import fs, { Dirent } from "fs";
import path from "path";
import "@/utils/remarkUtils";

jest.mock("@/utils/remarkUtils", () => ({
processMarkdownAsHTML: jest
.fn()
.mockResolvedValue("<p>Mocked HTML content</p>"),
}));

jest.mock("fs", () => ({
promises: {
access: jest.fn(),
readdir: jest.fn(),
readFile: jest.fn(),
},
}));

// Tests

describe("News Library", () => {
beforeEach(() => {
const mockFileNames = ["post1.md", "post2.md"];
const mockFileContents = [
'---\ntitle: Post 1\ndate: "2022-01-01"\n---\n\nPost 1 content',
'---\ntitle: Post 2\ndate: "2022-01-02"\n---\n\nPost 2 content',
];

jest.spyOn(fs.promises, "access").mockImplementation((filePath) => {
const index = mockFileNames.indexOf(path.basename(filePath.toString()));
return index >= 0 ? Promise.resolve() : Promise.reject();
});

jest
.spyOn(fs.promises, "readdir")
.mockResolvedValue(mockFileNames as unknown as Dirent[]);

jest.spyOn(fs.promises, "readFile").mockImplementation((filePath) => {
const index = mockFileNames.indexOf(path.basename(filePath.toString()));
return Promise.resolve(mockFileContents[index]);
});
});

describe("checkIfSlugIsValid", () => {
it("should return true if the slug is valid", async () => {
const slug = "post1";
const isValid = await newsLib.checkIfSlugIsValid(slug);
expect(isValid).toBe(true);
});

it("should return false if the slug is blank", async () => {
const slug = "";
const isValid = await newsLib.checkIfSlugIsValid(slug);
expect(isValid).toBe(false);
});

it("should return false if the slug is invalid", async () => {
const slug = "invalid-slug";
const isValid = await newsLib.checkIfSlugIsValid(slug);
expect(isValid).toBe(false);
});

it("should return false if the slug is in an invalid format", async () => {
const slug = "invalid/slug";
const isValid = await newsLib.checkIfSlugIsValid(slug);
expect(isValid).toBe(false);
});
});

describe("getSortedPostsData", () => {
it("should return an array of sorted post data", async () => {
const sortedPosts = await newsLib.getSortedPostsData();
expect(sortedPosts).toStrictEqual([
{ slug: "post2", date: "2022-01-02", title: "Post 2" },
{ slug: "post1", date: "2022-01-01", title: "Post 1" },
]);
});
});

describe("getAllPostSlugs", () => {
it("should return an array of all post slugs", async () => {
const postSlugs = await newsLib.getAllPostSlugs();
expect(postSlugs).toStrictEqual([
{ params: { slug: "post1" } },
{ params: { slug: "post2" } },
]);
});
});

describe("getPostData", () => {
it("should return the data of a specific post", async () => {
const slug = "post1";
const postData = await newsLib.getPostData(slug);
expect(postData).toBeDefined();
expect(postData.date.toString()).toBe("2022-01-01");
expect(postData.slug).toBe(slug);
expect(postData.contentHtml).toBe("<p>Mocked HTML content</p>");
});

it("should throw an error if the post does not exist", async () => {
const slug = "non-existent-post";
await expect(newsLib.getPostData(slug)).rejects.toThrow();
});
});
});
109 changes: 44 additions & 65 deletions lib/news.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,48 @@ import fs from "fs";
import path from "path";

import matter from "gray-matter";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import { processMarkdownAsHTML } from "@/utils/remarkUtils";

const postsDirectory = path.join(process.cwd(), "news");

// -------------------------------------------------
// GET THE DATA OF ALL POSTS IN SORTED ORDER BY DATE
/*
Returns an array that looks like this:
[
{
slug: 'ssg-ssr',
title: 'When to Use Static Generation v.s. Server-side Rendering',
date: '2020-01-01'
},
{
slug: 'pre-rendering',
title: 'Two Forms of Pre-rendering',
date: '2020-01-02'
}
]
*/
export function getSortedPostsData() {
const fileNames = fs.readdirSync(postsDirectory);
export async function checkIfSlugIsValid(slug: string) {
if (!slug || typeof slug !== "string") {
return false;
}

const allPostsData = fileNames.map((filename) => {
const slug = filename.replace(/\.md$/, "");
// Check that the slug does not contain any slashes to prevent directory traversal
if (slug.includes("/") || slug.includes("\\")) {
return false;
}

const fullPath = path.join(postsDirectory, filename);
const fileContents = fs.readFileSync(fullPath, "utf8");
const fullPath = path.join(postsDirectory, `${slug}.md`);

const matterResult = matter(fileContents);
try {
await fs.promises.access(fullPath);
return true;
} catch {
return false;
}
}

return {
slug,
...(matterResult.data as { date: string; title: string }),
};
});
export async function getSortedPostsData() {
const fileNames = await fs.promises.readdir(postsDirectory);

const allPostsData = await Promise.all(
fileNames.map(async (filename) => {
const slug = filename.replace(/\.md$/, "");

const fullPath = path.join(postsDirectory, filename);
const fileContents = await fs.promises.readFile(fullPath, "utf8");

const matterResult = matter(fileContents);

return {
slug,
...(matterResult.data as { date: string; title: string }),
};
})
);

return allPostsData.sort((a, b) => {
if (a.date < b.date) {
Expand All @@ -54,25 +54,8 @@ export function getSortedPostsData() {
});
}

// ------------------------------------------------
// GET THE SLUGS OF ALL POSTS FOR THE DYNAMIC ROUTING
/*
Returns an array that looks like this:
[
{
params: {
slug: 'ssg-ssr'
}
},
{
params: {
slug: 'pre-rendering'
}
}
]
*/
export function getAllPostSlugs() {
const fileNames = fs.readdirSync(postsDirectory);
export async function getAllPostSlugs() {
const fileNames = await fs.promises.readdir(postsDirectory);

return fileNames.map((fileName) => {
return {
Expand All @@ -83,22 +66,18 @@ export function getAllPostSlugs() {
});
}

// --------------------------------
// GET THE DATA OF A SINGLE POST FROM THE SLUG
export async function getPostData(slug: string) {
const fullPath = path.join(postsDirectory, `${slug}.md`);
const fileContents = fs.readFileSync(fullPath, "utf8");

const fileContents = await fs.promises.readFile(fullPath, "utf8");

if (!fileContents) {
throw new Error(`Post with slug "${slug}" does not exist.`);
}

const matterResult = matter(fileContents);

const processedContent = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeStringify)
.process(matterResult.content);
const contentHtml = processedContent.toString();
const contentHtml = await processMarkdownAsHTML(matterResult.content);

return {
slug,
Expand Down
6 changes: 6 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,11 @@
"description": "Migrate from other Enterprise Linux distributions without sweating it. We provide an easy-to-use migration script, free of charge."
}
}
},
"news": {
"notFound": {
"title": "Not Found",
"description": "The news post you are looking for does not exist. You can find all our news posts on the <newsLink>news page</newsLink>."
}
}
}
20 changes: 20 additions & 0 deletions utils/remarkUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";

export const processMarkdown = (content: string) => {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeStringify)
.process(content);
};

export const processMarkdownAsHTML = async (content: string) => {
return (await processMarkdown(content)).toString();
};
Loading