Skip to content

Commit

Permalink
Merge pull request #4 from rocky-linux/feature/fix-news
Browse files Browse the repository at this point in the history
Fix Possible News Problems and Add Tests
  • Loading branch information
FoggyMtnDrifter authored Feb 12, 2024
2 parents 361a160 + 361c5fc commit 64fe3bb
Show file tree
Hide file tree
Showing 6 changed files with 230 additions and 83 deletions.
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();
};

0 comments on commit 64fe3bb

Please sign in to comment.