Openstatus
www.openstatus.dev
1import fs from "node:fs";
2import path from "node:path";
3import slugify from "slugify";
4import { z } from "zod";
5
6const metadataSchema = z.object({
7 title: z.string(),
8 publishedAt: z.coerce.date(),
9 description: z.string(),
10 category: z.string(),
11 author: z.string(),
12 image: z.string().optional(),
13});
14
15export type Metadata = z.infer<typeof metadataSchema>;
16
17function parseFrontmatter(fileContent: string) {
18 const frontmatterRegex = /---\s*([\s\S]*?)\s*---/;
19 const match = frontmatterRegex.exec(fileContent);
20 const frontMatterBlock = match?.[1];
21 const content = fileContent.replace(frontmatterRegex, "").trim();
22 const frontMatterLines = frontMatterBlock?.trim().split("\n");
23 const metadata: Record<string, string> = {};
24
25 frontMatterLines?.forEach((line) => {
26 const [key, ...valueArr] = line.split(": ");
27 let value = valueArr.join(": ").trim();
28 value = value.replace(/^['"](.*)['"]$/, "$1"); // Remove quotes
29 metadata[key.trim()] = value;
30 });
31
32 const validatedMetadata = metadataSchema.safeParse(metadata);
33
34 if (!validatedMetadata.success) {
35 console.error(validatedMetadata.error);
36 throw new Error(`Invalid metadata ${fileContent}`);
37 }
38
39 return { metadata: validatedMetadata.data, content };
40}
41
42function getMDXFiles(dir: string) {
43 return fs.readdirSync(dir).filter((file) => path.extname(file) === ".mdx");
44}
45
46function readMDXFile(filePath: string) {
47 const rawContent = fs.readFileSync(filePath, "utf-8");
48 return parseFrontmatter(rawContent);
49}
50
51function getMDXDataFromDir(dir: string, prefix = "") {
52 const mdxFiles = getMDXFiles(dir);
53 return mdxFiles.map((file) => {
54 return getMDXDataFromFile(path.join(dir, file), prefix);
55 });
56}
57
58function getMDXDataFromFile(filePath: string, prefix = "") {
59 const { metadata, content } = readMDXFile(filePath);
60 const slugRaw = path.basename(filePath, path.extname(filePath));
61 const slug = slugify(slugRaw, { lower: true, strict: true });
62 const href = prefix ? `${prefix}/${slug}` : `/${slug}`;
63 return {
64 metadata,
65 slug,
66 content,
67 href,
68 };
69}
70
71export type MDXData = ReturnType<typeof getMDXDataFromFile>;
72
73export function getBlogPosts(): MDXData[] {
74 return getMDXDataFromDir(
75 path.join(process.cwd(), "src", "content", "pages", "blog"),
76 "/blog",
77 );
78}
79
80export function getChangelogPosts(): MDXData[] {
81 return getMDXDataFromDir(
82 path.join(process.cwd(), "src", "content", "pages", "changelog"),
83 "/changelog",
84 );
85}
86
87export function getProductPages(): MDXData[] {
88 return getMDXDataFromDir(
89 path.join(process.cwd(), "src", "content", "pages", "product"),
90 "",
91 );
92}
93
94export function getUnrelatedPages(): MDXData[] {
95 return getMDXDataFromDir(
96 path.join(process.cwd(), "src", "content", "pages", "unrelated"),
97 "",
98 );
99}
100
101export function getUnrelatedPage(slug: string): MDXData {
102 return getMDXDataFromFile(
103 path.join(
104 process.cwd(),
105 "src",
106 "content",
107 "pages",
108 "unrelated",
109 `${slug}.mdx`,
110 ),
111 "",
112 );
113}
114
115export function getMainPages(): MDXData[] {
116 return [...getUnrelatedPages(), ...getProductPages()];
117}
118
119export function getComparePages(): MDXData[] {
120 return getMDXDataFromDir(
121 path.join(process.cwd(), "src", "content", "pages", "compare"),
122 "/compare",
123 );
124}
125
126export function getHomePage(): MDXData {
127 return getMDXDataFromFile(
128 path.join(process.cwd(), "src", "content", "pages", "home.mdx"),
129 "",
130 );
131}
132
133export function getToolsPages(): MDXData[] {
134 return getMDXDataFromDir(
135 path.join(process.cwd(), "src", "content", "pages", "tools"),
136 "/play",
137 );
138}
139
140export function getToolsPage(slug: string): MDXData {
141 return getMDXDataFromFile(
142 path.join(process.cwd(), "src", "content", "pages", "tools", `${slug}.mdx`),
143 "/play",
144 );
145}
146
147export const PAGE_TYPES = [
148 "blog",
149 "changelog",
150 "product",
151 "unrelated",
152 "compare",
153 "tools",
154 "all",
155] as const;
156
157export type PageType = (typeof PAGE_TYPES)[number];
158
159export function getPages(type: PageType) {
160 switch (type) {
161 case "blog":
162 return getBlogPosts();
163 case "changelog":
164 return getChangelogPosts();
165 case "product":
166 return getProductPages();
167 case "unrelated":
168 return getUnrelatedPages();
169 case "compare":
170 return getComparePages();
171 case "tools":
172 return getToolsPages();
173 case "all":
174 return [
175 ...getBlogPosts(),
176 ...getChangelogPosts(),
177 ...getProductPages(),
178 ...getUnrelatedPages(),
179 ...getComparePages(),
180 ...getToolsPages(),
181 ];
182 default:
183 throw new Error(`Unknown page type: ${type}`);
184 }
185}
186
187export function getCategories() {
188 return [
189 ...new Set([
190 ...getBlogPosts().map((post) => post.metadata.category),
191 ...getChangelogPosts().map((post) => post.metadata.category),
192 ...getProductPages().map((post) => post.metadata.category),
193 ...getUnrelatedPages().map((post) => post.metadata.category),
194 ...getComparePages().map((post) => post.metadata.category),
195 ...getToolsPages().map((post) => post.metadata.category),
196 ]),
197 ] as const;
198}
199
200export function formatDate(targetDate: Date, includeRelative = false) {
201 const currentDate = new Date();
202
203 const yearsAgo = currentDate.getFullYear() - targetDate.getFullYear();
204 const monthsAgo = currentDate.getMonth() - targetDate.getMonth();
205 const daysAgo = currentDate.getDate() - targetDate.getDate();
206
207 let formattedDate = "";
208
209 if (yearsAgo > 0) {
210 formattedDate = `${yearsAgo}y ago`;
211 } else if (monthsAgo > 0) {
212 formattedDate = `${monthsAgo}mo ago`;
213 } else if (daysAgo > 0) {
214 formattedDate = `${daysAgo}d ago`;
215 } else {
216 formattedDate = "Today";
217 }
218
219 const fullDate = targetDate.toLocaleString("en-us", {
220 month: "short",
221 day: "2-digit",
222 year: "numeric",
223 });
224
225 if (!includeRelative) {
226 return fullDate;
227 }
228
229 return `${fullDate} (${formattedDate})`;
230}