Openstatus www.openstatus.dev
at main 241 lines 6.2 kB view raw
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}