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 getGuides(): MDXData[] {
95 return getMDXDataFromDir(
96 path.join(process.cwd(), "src", "content", "pages", "guides"),
97 "/guides",
98 );
99}
100
101export function getUnrelatedPages(): MDXData[] {
102 return getMDXDataFromDir(
103 path.join(process.cwd(), "src", "content", "pages", "unrelated"),
104 "",
105 );
106}
107
108export function getUnrelatedPage(slug: string): MDXData {
109 return getMDXDataFromFile(
110 path.join(
111 process.cwd(),
112 "src",
113 "content",
114 "pages",
115 "unrelated",
116 `${slug}.mdx`,
117 ),
118 "",
119 );
120}
121
122export function getMainPages(): MDXData[] {
123 return [...getUnrelatedPages(), ...getProductPages()];
124}
125
126export function getComparePages(): MDXData[] {
127 return getMDXDataFromDir(
128 path.join(process.cwd(), "src", "content", "pages", "compare"),
129 "/compare",
130 );
131}
132
133export function getHomePage(): MDXData {
134 return getMDXDataFromFile(
135 path.join(process.cwd(), "src", "content", "pages", "home.mdx"),
136 "",
137 );
138}
139
140export function getToolsPages(): MDXData[] {
141 return getMDXDataFromDir(
142 path.join(process.cwd(), "src", "content", "pages", "tools"),
143 "/play",
144 );
145}
146
147export function getToolsPage(slug: string): MDXData {
148 return getMDXDataFromFile(
149 path.join(process.cwd(), "src", "content", "pages", "tools", `${slug}.mdx`),
150 "/play",
151 );
152}
153
154export const PAGE_TYPES = [
155 "blog",
156 "changelog",
157 "product",
158 "unrelated",
159 "compare",
160 "tools",
161 "guides",
162 "all",
163] as const;
164
165export type PageType = (typeof PAGE_TYPES)[number];
166
167export function getPages(type: PageType) {
168 switch (type) {
169 case "blog":
170 return getBlogPosts();
171 case "changelog":
172 return getChangelogPosts();
173 case "product":
174 return getProductPages();
175 case "unrelated":
176 return getUnrelatedPages();
177 case "compare":
178 return getComparePages();
179 case "tools":
180 return getToolsPages();
181 case "guides":
182 return getGuides();
183 case "all":
184 return [
185 ...getBlogPosts(),
186 ...getChangelogPosts(),
187 ...getProductPages(),
188 ...getUnrelatedPages(),
189 ...getComparePages(),
190 ...getToolsPages(),
191 ...getGuides(),
192 ];
193 default:
194 throw new Error(`Unknown page type: ${type}`);
195 }
196}
197
198export function getCategories() {
199 return [
200 ...new Set([
201 ...getBlogPosts().map((post) => post.metadata.category),
202 ...getChangelogPosts().map((post) => post.metadata.category),
203 ...getProductPages().map((post) => post.metadata.category),
204 ...getUnrelatedPages().map((post) => post.metadata.category),
205 ...getComparePages().map((post) => post.metadata.category),
206 ...getToolsPages().map((post) => post.metadata.category),
207 ]),
208 ] as const;
209}
210
211export function formatDate(targetDate: Date, includeRelative = false) {
212 const currentDate = new Date();
213
214 const yearsAgo = currentDate.getFullYear() - targetDate.getFullYear();
215 const monthsAgo = currentDate.getMonth() - targetDate.getMonth();
216 const daysAgo = currentDate.getDate() - targetDate.getDate();
217
218 let formattedDate = "";
219
220 if (yearsAgo > 0) {
221 formattedDate = `${yearsAgo}y ago`;
222 } else if (monthsAgo > 0) {
223 formattedDate = `${monthsAgo}mo ago`;
224 } else if (daysAgo > 0) {
225 formattedDate = `${daysAgo}d ago`;
226 } else {
227 formattedDate = "Today";
228 }
229
230 const fullDate = targetDate.toLocaleString("en-us", {
231 month: "short",
232 day: "2-digit",
233 year: "numeric",
234 });
235
236 if (!includeRelative) {
237 return fullDate;
238 }
239
240 return `${fullDate} (${formattedDate})`;
241}