mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
425 lines
13 KiB
TypeScript
425 lines
13 KiB
TypeScript
import URL from "Common/Types/API/URL";
|
|
import OneUptimeDate from "Common/Types/Date";
|
|
import BadDataException from "Common/Types/Exception/BadDataException";
|
|
import { JSONArray, JSONObject } from "Common/Types/JSON";
|
|
import JSONFunctions from "Common/Types/JSONFunctions";
|
|
import Text from "Common/Types/Text";
|
|
import Markdown, { MarkdownContentType } from "Common/Server/Types/Markdown";
|
|
import { BlogRootPath } from "./Config";
|
|
import LocalFile from "Common/Server/Utils/LocalFile";
|
|
import DatabaseConfig from "Common/Server/DatabaseConfig";
|
|
|
|
export interface BlogPostAuthor {
|
|
username: string;
|
|
githubUrl: string;
|
|
profileImageUrl: string;
|
|
name: string;
|
|
bio?: string | undefined; // optional bio from Authors.json
|
|
}
|
|
|
|
export interface BlogPostBaseProps {
|
|
title: string;
|
|
description: string;
|
|
|
|
formattedPostDate: string;
|
|
fileName: string;
|
|
tags: string[];
|
|
postDate: string;
|
|
blogUrl: string;
|
|
}
|
|
|
|
export interface BlogPostHeader extends BlogPostBaseProps {
|
|
authorGitHubUsername: string;
|
|
}
|
|
|
|
export interface BlogPost extends BlogPostBaseProps {
|
|
htmlBody: string;
|
|
markdownBody: string;
|
|
socialMediaImageUrl: string;
|
|
author: BlogPostAuthor | null;
|
|
}
|
|
|
|
export default class BlogPostUtil {
|
|
// Cache Blogs.json contents to avoid repeated disk reads and external calls.
|
|
private static blogsMetaCache: Array<JSONObject> | null = null;
|
|
// Cache Authors.json (keyed by github username)
|
|
private static authorsMetaCache: JSONObject | null = null;
|
|
// Cache the constructed blog post header list
|
|
private static blogPostListCache: Array<BlogPostHeader> | null = null;
|
|
// Cache tags from Tags.md
|
|
private static tagsCache: string[] | null = null;
|
|
// Cache fully rendered blog posts by fileName
|
|
private static blogPostCache: Map<string, BlogPost> = new Map();
|
|
|
|
public static clearAllCaches(): void {
|
|
this.blogsMetaCache = null;
|
|
this.authorsMetaCache = null;
|
|
this.blogPostListCache = null;
|
|
this.tagsCache = null;
|
|
this.blogPostCache.clear();
|
|
}
|
|
private static async getBlogsMeta(): Promise<Array<JSONObject>> {
|
|
if (this.blogsMetaCache) {
|
|
return this.blogsMetaCache;
|
|
}
|
|
|
|
const filePath: string = `${BlogRootPath}/Blogs.json`;
|
|
let jsonContent: string | JSONArray = await LocalFile.read(filePath);
|
|
if (typeof jsonContent === "string") {
|
|
jsonContent = JSONFunctions.parseJSONArray(jsonContent);
|
|
}
|
|
const blogs: Array<JSONObject> = JSONFunctions.deserializeArray(
|
|
jsonContent as Array<JSONObject>,
|
|
);
|
|
this.blogsMetaCache = blogs;
|
|
return blogs;
|
|
}
|
|
|
|
private static async getAuthorsMeta(): Promise<JSONObject> {
|
|
if (this.authorsMetaCache) {
|
|
return this.authorsMetaCache;
|
|
}
|
|
const filePath: string = `${BlogRootPath}/Authors.json`;
|
|
try {
|
|
let jsonContent: string | JSONObject = await LocalFile.read(filePath);
|
|
if (typeof jsonContent === "string") {
|
|
jsonContent = JSONFunctions.parse(jsonContent) as JSONObject;
|
|
}
|
|
this.authorsMetaCache = jsonContent as JSONObject;
|
|
return this.authorsMetaCache || ({} as JSONObject);
|
|
} catch {
|
|
this.authorsMetaCache = {} as JSONObject;
|
|
return this.authorsMetaCache;
|
|
}
|
|
}
|
|
|
|
public static async getBlogPostList(
|
|
tagName?: string | undefined,
|
|
): Promise<BlogPostHeader[]> {
|
|
if (!this.blogPostListCache) {
|
|
const blogs: Array<JSONObject> = [
|
|
...(await this.getBlogsMeta()),
|
|
].reverse(); // reverse so new content comes first
|
|
|
|
const resultList: Array<BlogPostHeader> = [];
|
|
|
|
for (const blog of blogs) {
|
|
const fileName: string = blog["post"] as string;
|
|
const formattedPostDate: string =
|
|
this.getFormattedPostDateFromFileName(fileName);
|
|
const postDate: string = this.getPostDateFromFileName(fileName);
|
|
|
|
resultList.push({
|
|
title: blog["title"] as string,
|
|
description: blog["description"] as string,
|
|
fileName,
|
|
formattedPostDate,
|
|
postDate,
|
|
tags: blog["tags"] as string[],
|
|
authorGitHubUsername: blog["authorGitHubUsername"] as string,
|
|
blogUrl: `/blog/post/${fileName}/view`,
|
|
});
|
|
}
|
|
|
|
this.blogPostListCache = resultList;
|
|
}
|
|
|
|
if (tagName) {
|
|
return this.blogPostListCache.filter((blog: BlogPostHeader) => {
|
|
return blog.tags
|
|
.map((item: string) => {
|
|
return Text.replaceAll(item.toLowerCase(), " ", "-");
|
|
})
|
|
.includes(tagName);
|
|
});
|
|
}
|
|
|
|
return this.blogPostListCache;
|
|
}
|
|
|
|
public static async getBlogPost(fileName: string): Promise<BlogPost | null> {
|
|
const cached: BlogPost | undefined = this.blogPostCache.get(fileName);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
try {
|
|
const blogPost: BlogPost | null =
|
|
await this.getBlogPostFromFile(fileName);
|
|
if (blogPost) {
|
|
this.blogPostCache.set(fileName, blogPost);
|
|
}
|
|
return blogPost;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public static async getTags(): Promise<string[]> {
|
|
if (this.tagsCache) {
|
|
return this.tagsCache;
|
|
}
|
|
|
|
const tags: string[] = await this.getAllTagsFromGitHub();
|
|
this.tagsCache = tags;
|
|
return tags;
|
|
}
|
|
|
|
public static async getAllTagsFromGitHub(): Promise<string[]> {
|
|
const filePath: string = `${BlogRootPath}/Tags.md`;
|
|
|
|
const tagsMarkdownContent: string | null = await LocalFile.read(filePath);
|
|
|
|
if (!tagsMarkdownContent) {
|
|
return [];
|
|
}
|
|
|
|
const tags: Array<string> = tagsMarkdownContent
|
|
.split("\n")
|
|
.map((tag: string) => {
|
|
return tag.trim();
|
|
})
|
|
.filter((tag: string) => {
|
|
return tag.startsWith("-");
|
|
})
|
|
.map((tag: string) => {
|
|
return tag.replace("-", "").trim();
|
|
});
|
|
|
|
return tags;
|
|
}
|
|
|
|
public static async getHomeUrl(): Promise<URL> {
|
|
return await DatabaseConfig.getHomeUrl();
|
|
}
|
|
|
|
public static async getBlogPostFromFile(
|
|
fileName: string,
|
|
): Promise<BlogPost | null> {
|
|
const filePath: string = `${BlogRootPath}/posts/${fileName}/README.md`;
|
|
|
|
const postDate: string = this.getPostDateFromFileName(fileName);
|
|
const formattedPostDate: string =
|
|
this.getFormattedPostDateFromFileName(fileName);
|
|
|
|
let markdownContent: string = await LocalFile.read(filePath);
|
|
|
|
// Resolve author WITHOUT hitting GitHub API. Use Blogs.json to get username, Authors.json for name/bio.
|
|
let blogPostAuthor: BlogPostAuthor | null = null;
|
|
try {
|
|
const blogsMeta: Array<JSONObject> = await this.getBlogsMeta();
|
|
const blogMeta: JSONObject | undefined = blogsMeta.find(
|
|
(b: JSONObject) => {
|
|
return (b["post"] as string) === fileName;
|
|
},
|
|
);
|
|
const username: string | undefined = blogMeta?.[
|
|
"authorGitHubUsername"
|
|
] as string | undefined;
|
|
if (username) {
|
|
const authorsMeta: JSONObject = await this.getAuthorsMeta();
|
|
const authorMeta: JSONObject | undefined = authorsMeta[username] as
|
|
| JSONObject
|
|
| undefined;
|
|
const authorName: string | undefined =
|
|
(authorMeta?.["authorName"] as string) || undefined;
|
|
const authorBio: string | undefined =
|
|
(authorMeta?.["authorBio"] as string) || undefined;
|
|
blogPostAuthor = {
|
|
username,
|
|
githubUrl: `https://github.com/${username}`,
|
|
profileImageUrl: `https://avatars.githubusercontent.com/${username}`,
|
|
name: authorName || username,
|
|
bio: authorBio,
|
|
};
|
|
}
|
|
} catch {
|
|
// ignore and fallback
|
|
}
|
|
|
|
// Fallback to parsing markdown (no network) if metadata missing.
|
|
if (!blogPostAuthor) {
|
|
blogPostAuthor = await this.getAuthorFromFileContent(markdownContent);
|
|
}
|
|
|
|
const title: string = this.getTitleFromFileContent(markdownContent);
|
|
const description: string =
|
|
this.getDescriptionFromFileContent(markdownContent);
|
|
const tags: Array<string> = this.getTagsFromFileContent(markdownContent);
|
|
|
|
markdownContent = this.getPostFromMarkdown(markdownContent);
|
|
|
|
const htmlBody: string = await Markdown.convertToHTML(
|
|
markdownContent,
|
|
MarkdownContentType.Blog,
|
|
);
|
|
|
|
const blogPost: BlogPost = {
|
|
title,
|
|
description,
|
|
author: blogPostAuthor,
|
|
htmlBody,
|
|
markdownBody: markdownContent,
|
|
fileName,
|
|
tags,
|
|
postDate,
|
|
formattedPostDate,
|
|
socialMediaImageUrl: `${(await this.getHomeUrl()).toString()}blog/post/${fileName}/social-media.png`,
|
|
blogUrl: `${(await this.getHomeUrl()).toString()}blog/post/${fileName}/view`,
|
|
};
|
|
|
|
return blogPost;
|
|
}
|
|
|
|
private static getPostDateFromFileName(fileName: string): string {
|
|
const year: string | undefined = fileName.split("-")[0];
|
|
const month: string | undefined = fileName.split("-")[1];
|
|
const day: string | undefined = fileName.split("-")[2];
|
|
|
|
if (!year || !month || !day) {
|
|
throw new BadDataException("Invalid file name");
|
|
}
|
|
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
private static getFormattedPostDateFromFileName(fileName: string): string {
|
|
// file name is of the format YYYY-MM-DD-Title.md
|
|
const year: string | undefined = fileName.split("-")[0];
|
|
const month: string | undefined = fileName.split("-")[1];
|
|
const day: string | undefined = fileName.split("-")[2];
|
|
|
|
if (!year || !month || !day) {
|
|
throw new BadDataException("Invalid file name");
|
|
}
|
|
|
|
const date: Date = OneUptimeDate.getDateFromYYYYMMDD(year, month, day);
|
|
return OneUptimeDate.getDateAsLocalFormattedString(date, true);
|
|
}
|
|
|
|
private static getPostFromMarkdown(markdownContent: string): string {
|
|
const authorLine: string | undefined = markdownContent
|
|
.split("\n")
|
|
.find((line: string) => {
|
|
return line.startsWith("Author:");
|
|
});
|
|
const titleLine: string | undefined = markdownContent
|
|
.split("\n")
|
|
.find((line: string) => {
|
|
return line.startsWith("#");
|
|
});
|
|
const descriptionLine: string | undefined =
|
|
markdownContent.split("\n").find((line: string) => {
|
|
return line.startsWith("Description:");
|
|
}) || "";
|
|
|
|
const tagsLine: string | undefined =
|
|
markdownContent.split("\n").find((line: string) => {
|
|
return line.startsWith("Tags:");
|
|
}) || "";
|
|
|
|
if (!authorLine && !titleLine && !descriptionLine && !tagsLine) {
|
|
return markdownContent;
|
|
}
|
|
|
|
const lines: string[] = markdownContent.split("\n");
|
|
|
|
if (authorLine) {
|
|
const authorLineIndex: number = lines.indexOf(authorLine);
|
|
lines.splice(authorLineIndex, 1);
|
|
}
|
|
|
|
if (titleLine) {
|
|
const titleLineIndex: number = lines.indexOf(titleLine);
|
|
lines.splice(titleLineIndex, 1);
|
|
}
|
|
|
|
if (descriptionLine) {
|
|
const descriptionLineIndex: number = lines.indexOf(descriptionLine);
|
|
lines.splice(descriptionLineIndex, 1);
|
|
}
|
|
|
|
if (tagsLine) {
|
|
const tagsLineIndex: number = lines.indexOf(tagsLine);
|
|
lines.splice(tagsLineIndex, 1);
|
|
}
|
|
|
|
return lines.join("\n").trim();
|
|
}
|
|
|
|
public static getTitleFromFileContent(fileContent: string): string {
|
|
// title is the first line that stars with "#"
|
|
|
|
const titleLine: string =
|
|
fileContent
|
|
.split("\n")
|
|
.find((line: string) => {
|
|
return line.startsWith("#");
|
|
})
|
|
?.replace("#", "") || "OneUptime Blog";
|
|
|
|
return titleLine;
|
|
}
|
|
|
|
public static getTagsFromFileContent(fileContent: string): string[] {
|
|
// tags is the first line that starts with "Tags:"
|
|
|
|
const tagsLine: string | undefined =
|
|
fileContent
|
|
.split("\n")
|
|
.find((line: string) => {
|
|
return line.startsWith("Tags:");
|
|
})
|
|
?.replace("Tags:", "") || "";
|
|
|
|
return tagsLine.split(",").map((tag: string) => {
|
|
return tag.trim();
|
|
});
|
|
}
|
|
|
|
public static getDescriptionFromFileContent(fileContent: string): string {
|
|
// description is the first line that starts with ">"
|
|
|
|
const descriptionLine: string | undefined =
|
|
fileContent
|
|
.split("\n")
|
|
.find((line: string) => {
|
|
return line.startsWith("Description:");
|
|
})
|
|
?.replace("Description:", "") || "";
|
|
|
|
return descriptionLine;
|
|
}
|
|
|
|
public static async getAuthorFromFileContent(
|
|
fileContent: string,
|
|
): Promise<BlogPostAuthor | null> {
|
|
// author line is in this format: Author: [username](githubUrl)
|
|
|
|
const authorLine: string | undefined = fileContent
|
|
.split("\n")
|
|
.find((line: string) => {
|
|
return line.startsWith("Author:");
|
|
});
|
|
const authorUsername: string | undefined = authorLine
|
|
?.split("[")[1]
|
|
?.split("]")[0];
|
|
const authorGitHubUrl: string | undefined = authorLine
|
|
?.split("(")[1]
|
|
?.split(")")[0];
|
|
const authorProfileImageUrl: string = `https://avatars.githubusercontent.com/${authorUsername}`;
|
|
|
|
if (!authorUsername || !authorGitHubUrl) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
username: authorUsername,
|
|
githubUrl: authorGitHubUrl,
|
|
profileImageUrl: authorProfileImageUrl,
|
|
// Do NOT call GitHub; use username as name placeholder.
|
|
name: authorUsername,
|
|
};
|
|
}
|
|
}
|