mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
527 lines
15 KiB
TypeScript
527 lines
15 KiB
TypeScript
import { create } from "xmlbuilder2";
|
|
import { XMLBuilder } from "xmlbuilder2/lib/interfaces";
|
|
import BlogPostUtil, { BlogPostHeader } from "./BlogPost";
|
|
import OneUptimeDate from "Common/Types/Date";
|
|
import URL from "Common/Types/API/URL";
|
|
import { getProductCompareSlugs } from "./ProductCompare";
|
|
import Express, { ExpressApplication } from "Common/Server/Utils/Express";
|
|
|
|
// Priority and changefreq configuration for different page types
|
|
type ChangeFrequency =
|
|
| "always"
|
|
| "hourly"
|
|
| "daily"
|
|
| "weekly"
|
|
| "monthly"
|
|
| "yearly"
|
|
| "never";
|
|
|
|
interface SitemapPageConfig {
|
|
priority: number;
|
|
changefreq: ChangeFrequency;
|
|
}
|
|
|
|
const PAGE_CONFIG: Record<string, SitemapPageConfig> = {
|
|
// Homepage - highest priority
|
|
"/": { priority: 1.0, changefreq: "daily" },
|
|
|
|
// Core product pages - high priority
|
|
"/product/status-page": { priority: 0.9, changefreq: "weekly" },
|
|
"/product/monitoring": { priority: 0.9, changefreq: "weekly" },
|
|
"/product/incident-management": { priority: 0.9, changefreq: "weekly" },
|
|
"/product/on-call": { priority: 0.9, changefreq: "weekly" },
|
|
"/product/logs-management": { priority: 0.9, changefreq: "weekly" },
|
|
"/product/metrics": { priority: 0.9, changefreq: "weekly" },
|
|
"/product/traces": { priority: 0.9, changefreq: "weekly" },
|
|
"/product/exceptions": { priority: 0.9, changefreq: "weekly" },
|
|
"/product/workflows": { priority: 0.9, changefreq: "weekly" },
|
|
"/product/dashboards": { priority: 0.9, changefreq: "weekly" },
|
|
"/product/ai-agent": { priority: 0.9, changefreq: "weekly" },
|
|
"/product/scheduled-maintenance": { priority: 0.9, changefreq: "weekly" },
|
|
|
|
// Teams (Solutions) pages
|
|
"/solutions/devops": { priority: 0.8, changefreq: "weekly" },
|
|
"/solutions/sre": { priority: 0.8, changefreq: "weekly" },
|
|
"/solutions/platform": { priority: 0.8, changefreq: "weekly" },
|
|
"/solutions/developers": { priority: 0.8, changefreq: "weekly" },
|
|
|
|
// Industry pages
|
|
"/industries/fintech": { priority: 0.8, changefreq: "weekly" },
|
|
"/industries/saas": { priority: 0.8, changefreq: "weekly" },
|
|
"/industries/healthcare": { priority: 0.8, changefreq: "weekly" },
|
|
"/industries/ecommerce": { priority: 0.8, changefreq: "weekly" },
|
|
"/industries/media": { priority: 0.8, changefreq: "weekly" },
|
|
"/industries/government": { priority: 0.8, changefreq: "weekly" },
|
|
|
|
// Important pages
|
|
"/pricing": { priority: 0.9, changefreq: "weekly" },
|
|
"/enterprise/demo": { priority: 0.9, changefreq: "weekly" },
|
|
"/enterprise/overview": { priority: 0.8, changefreq: "weekly" },
|
|
"/about": { priority: 0.7, changefreq: "weekly" },
|
|
"/support": { priority: 0.7, changefreq: "weekly" },
|
|
|
|
// Documentation and reference
|
|
"/docs": { priority: 0.7, changefreq: "weekly" },
|
|
"/reference": { priority: 0.7, changefreq: "weekly" },
|
|
|
|
// Blog section
|
|
"/blog": { priority: 0.7, changefreq: "daily" },
|
|
|
|
// Community and legal
|
|
"/oss-friends": { priority: 0.3, changefreq: "monthly" },
|
|
};
|
|
|
|
// Default config for pages not explicitly listed
|
|
const DEFAULT_CONFIG: SitemapPageConfig = {
|
|
priority: 0.5,
|
|
changefreq: "monthly",
|
|
};
|
|
|
|
// Blog post config
|
|
const BLOG_POST_CONFIG: SitemapPageConfig = {
|
|
priority: 0.6,
|
|
changefreq: "monthly",
|
|
};
|
|
|
|
// Blog tag config
|
|
const BLOG_TAG_CONFIG: SitemapPageConfig = {
|
|
priority: 0.4,
|
|
changefreq: "weekly",
|
|
};
|
|
|
|
// Compare page config
|
|
const COMPARE_PAGE_CONFIG: SitemapPageConfig = {
|
|
priority: 0.7,
|
|
changefreq: "monthly",
|
|
};
|
|
|
|
// Number of blog posts per sitemap file
|
|
const BLOG_POSTS_PER_SITEMAP: number = 1000;
|
|
|
|
// Number of tags per sitemap file
|
|
const TAGS_PER_SITEMAP: number = 500;
|
|
|
|
// Cache TTL: 10 minutes
|
|
const TTL_MS: number = 10 * 60 * 1000;
|
|
|
|
interface CachedData<T> {
|
|
data: T;
|
|
generatedAt: number;
|
|
}
|
|
|
|
// Caches for different sitemap types
|
|
let indexCache: CachedData<string> | null = null;
|
|
let pagesCache: CachedData<string> | null = null;
|
|
let compareCache: CachedData<string> | null = null;
|
|
const tagsCaches: Map<number, CachedData<string>> = new Map();
|
|
const blogCaches: Map<number, CachedData<string>> = new Map();
|
|
|
|
// Cache for blog post count to avoid repeated fetches
|
|
let blogPostCountCache: CachedData<number> | null = null;
|
|
|
|
// Cache for tags count
|
|
let tagsCountCache: CachedData<number> | null = null;
|
|
|
|
function isCacheValid<T>(cache: CachedData<T> | null | undefined): boolean {
|
|
if (!cache) {
|
|
return false;
|
|
}
|
|
const now: number = OneUptimeDate.getCurrentDate().getTime();
|
|
return now - cache.generatedAt < TTL_MS;
|
|
}
|
|
|
|
function getPageConfig(path: string): SitemapPageConfig {
|
|
// Check for exact match first
|
|
if (PAGE_CONFIG[path]) {
|
|
return PAGE_CONFIG[path];
|
|
}
|
|
|
|
// Check for prefix matches
|
|
if (path.startsWith("/legal")) {
|
|
return { priority: 0.3, changefreq: "monthly" };
|
|
}
|
|
|
|
return DEFAULT_CONFIG;
|
|
}
|
|
|
|
interface SitemapEntry {
|
|
loc: string;
|
|
lastmod: string;
|
|
priority: number;
|
|
changefreq: ChangeFrequency;
|
|
}
|
|
|
|
function buildUrlsetXml(entries: SitemapEntry[]): string {
|
|
const urlset: XMLBuilder = create().ele("urlset");
|
|
urlset.att("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9");
|
|
|
|
for (const entry of entries) {
|
|
const urlEle: XMLBuilder = urlset.ele("url");
|
|
urlEle.ele("loc").txt(entry.loc);
|
|
urlEle.ele("lastmod").txt(entry.lastmod);
|
|
urlEle.ele("changefreq").txt(entry.changefreq);
|
|
urlEle.ele("priority").txt(entry.priority.toFixed(1));
|
|
}
|
|
|
|
return urlset.end({ prettyPrint: true });
|
|
}
|
|
|
|
interface SitemapIndexEntry {
|
|
loc: string;
|
|
lastmod: string;
|
|
}
|
|
|
|
function buildSitemapIndexXml(entries: SitemapIndexEntry[]): string {
|
|
const sitemapindex: XMLBuilder = create().ele("sitemapindex");
|
|
sitemapindex.att("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9");
|
|
|
|
for (const entry of entries) {
|
|
const sitemapEle: XMLBuilder = sitemapindex.ele("sitemap");
|
|
sitemapEle.ele("loc").txt(entry.loc);
|
|
sitemapEle.ele("lastmod").txt(entry.lastmod);
|
|
}
|
|
|
|
return sitemapindex.end({ prettyPrint: true });
|
|
}
|
|
|
|
// Get total blog post count (cached)
|
|
async function getBlogPostCount(): Promise<number> {
|
|
if (isCacheValid(blogPostCountCache)) {
|
|
return blogPostCountCache!.data;
|
|
}
|
|
|
|
const blogPosts: Array<BlogPostHeader> = await BlogPostUtil.getBlogPostList();
|
|
const count: number = blogPosts.length;
|
|
|
|
blogPostCountCache = {
|
|
data: count,
|
|
generatedAt: OneUptimeDate.getCurrentDate().getTime(),
|
|
};
|
|
|
|
return count;
|
|
}
|
|
|
|
// Calculate number of blog sitemap pages needed
|
|
export async function getBlogSitemapPageCount(): Promise<number> {
|
|
const totalPosts: number = await getBlogPostCount();
|
|
return Math.ceil(totalPosts / BLOG_POSTS_PER_SITEMAP);
|
|
}
|
|
|
|
// Get total tags count (cached)
|
|
async function getTagsCount(): Promise<number> {
|
|
if (isCacheValid(tagsCountCache)) {
|
|
return tagsCountCache!.data;
|
|
}
|
|
|
|
const tags: string[] = await BlogPostUtil.getTags();
|
|
const count: number = tags.length;
|
|
|
|
tagsCountCache = {
|
|
data: count,
|
|
generatedAt: OneUptimeDate.getCurrentDate().getTime(),
|
|
};
|
|
|
|
return count;
|
|
}
|
|
|
|
// Calculate number of tags sitemap pages needed
|
|
export async function getTagsSitemapPageCount(): Promise<number> {
|
|
const totalTags: number = await getTagsCount();
|
|
return Math.ceil(totalTags / TAGS_PER_SITEMAP);
|
|
}
|
|
|
|
// Discover static paths from Express routes
|
|
function discoverStaticPaths(): string[] {
|
|
const discoveredStaticPaths: Set<string> = new Set();
|
|
|
|
try {
|
|
const app: ExpressApplication = Express.getExpressApp();
|
|
const stack: any[] = (app as any)?._router?.stack || [];
|
|
|
|
for (const layer of stack) {
|
|
if (!layer) {
|
|
continue;
|
|
}
|
|
const route: any = layer.route || layer?.handle?.route;
|
|
if (!route) {
|
|
continue;
|
|
}
|
|
// Only include GET handlers
|
|
const methods: any = route.methods || {};
|
|
if (!methods.get) {
|
|
continue;
|
|
}
|
|
const path: string | string[] | undefined =
|
|
route.path || route?.route?.path;
|
|
const rawPaths: Array<string | undefined> = Array.isArray(path)
|
|
? path
|
|
: [path];
|
|
const paths: string[] = rawPaths.filter(
|
|
(p: string | undefined): p is string => {
|
|
return Boolean(p);
|
|
},
|
|
);
|
|
|
|
for (let p of paths) {
|
|
if (!p || typeof p !== "string") {
|
|
continue;
|
|
}
|
|
// Filters: skip parameterized, wildcard, api or file-serving or sitemap itself
|
|
if (p.includes(":") || p.includes("*") || p.includes("sitemap")) {
|
|
continue;
|
|
}
|
|
// Exclude script or installer endpoints
|
|
if (p.endsWith(".sh")) {
|
|
continue;
|
|
}
|
|
if (p.startsWith("/api") || p.startsWith("/blog/post")) {
|
|
continue;
|
|
}
|
|
// We'll add compare pages separately with real slugs; skip base compare param route
|
|
if (p.startsWith("/compare")) {
|
|
continue;
|
|
}
|
|
// Normalize slash
|
|
if (!p.startsWith("/")) {
|
|
p = `/${p}`;
|
|
}
|
|
discoveredStaticPaths.add(p);
|
|
}
|
|
}
|
|
|
|
// Ensure root present
|
|
discoveredStaticPaths.add("/");
|
|
// Ensure docs main landing page present
|
|
discoveredStaticPaths.add("/docs");
|
|
// add /reference
|
|
discoveredStaticPaths.add("/reference");
|
|
} catch {
|
|
// If introspection fails, fall back to minimal set
|
|
discoveredStaticPaths.add("/");
|
|
discoveredStaticPaths.add("/blog");
|
|
}
|
|
|
|
return Array.from(discoveredStaticPaths);
|
|
}
|
|
|
|
// Generate the main sitemap index
|
|
export async function generateSitemapIndexXml(): Promise<string> {
|
|
if (isCacheValid(indexCache)) {
|
|
return indexCache!.data;
|
|
}
|
|
|
|
const baseUrl: URL = await BlogPostUtil.getHomeUrl();
|
|
const baseUrlString: string = baseUrl.toString().replace(/\/$/, "");
|
|
const timestamp: string = OneUptimeDate.getCurrentDate().toISOString();
|
|
|
|
const sitemaps: SitemapIndexEntry[] = [];
|
|
|
|
// Static pages sitemap
|
|
sitemaps.push({
|
|
loc: `${baseUrlString}/sitemap-pages.xml`,
|
|
lastmod: timestamp,
|
|
});
|
|
|
|
// Compare pages sitemap
|
|
sitemaps.push({
|
|
loc: `${baseUrlString}/sitemap-compare.xml`,
|
|
lastmod: timestamp,
|
|
});
|
|
|
|
/*
|
|
* Note: Blog tag sitemaps removed - tag pages are noindex to improve
|
|
* site quality signals and crawl budget efficiency
|
|
*/
|
|
|
|
// Blog post sitemaps (paginated)
|
|
const blogPageCount: number = await getBlogSitemapPageCount();
|
|
for (let i: number = 1; i <= blogPageCount; i++) {
|
|
sitemaps.push({
|
|
loc: `${baseUrlString}/sitemap-blog-${i}.xml`,
|
|
lastmod: timestamp,
|
|
});
|
|
}
|
|
|
|
const xml: string = buildSitemapIndexXml(sitemaps);
|
|
|
|
indexCache = {
|
|
data: xml,
|
|
generatedAt: OneUptimeDate.getCurrentDate().getTime(),
|
|
};
|
|
|
|
return xml;
|
|
}
|
|
|
|
// Generate sitemap for static pages
|
|
export async function generatePagesSitemapXml(): Promise<string> {
|
|
if (isCacheValid(pagesCache)) {
|
|
return pagesCache!.data;
|
|
}
|
|
|
|
const baseUrl: URL = await BlogPostUtil.getHomeUrl();
|
|
const baseUrlString: string = baseUrl.toString().replace(/\/$/, "");
|
|
const timestamp: string = OneUptimeDate.getCurrentDate().toISOString();
|
|
|
|
const staticPaths: string[] = discoverStaticPaths();
|
|
|
|
const entries: SitemapEntry[] = staticPaths.map((p: string) => {
|
|
const config: SitemapPageConfig = getPageConfig(p);
|
|
const pathWithoutLeadingSlash: string = p.replace(/^\//, "");
|
|
return {
|
|
loc:
|
|
pathWithoutLeadingSlash === ""
|
|
? baseUrlString
|
|
: `${baseUrlString}/${pathWithoutLeadingSlash}`,
|
|
lastmod: timestamp,
|
|
priority: config.priority,
|
|
changefreq: config.changefreq,
|
|
};
|
|
});
|
|
|
|
// Sort with homepage first
|
|
entries.sort((a: SitemapEntry, b: SitemapEntry) => {
|
|
if (a.loc === baseUrlString || a.loc === `${baseUrlString}/`) {
|
|
return -1;
|
|
}
|
|
if (b.loc === baseUrlString || b.loc === `${baseUrlString}/`) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
});
|
|
|
|
const xml: string = buildUrlsetXml(entries);
|
|
|
|
pagesCache = {
|
|
data: xml,
|
|
generatedAt: OneUptimeDate.getCurrentDate().getTime(),
|
|
};
|
|
|
|
return xml;
|
|
}
|
|
|
|
// Generate sitemap for product compare pages
|
|
export async function generateCompareSitemapXml(): Promise<string> {
|
|
if (isCacheValid(compareCache)) {
|
|
return compareCache!.data;
|
|
}
|
|
|
|
const baseUrl: URL = await BlogPostUtil.getHomeUrl();
|
|
const baseUrlString: string = baseUrl.toString().replace(/\/$/, "");
|
|
const timestamp: string = OneUptimeDate.getCurrentDate().toISOString();
|
|
|
|
const productCompareSlugs: string[] = getProductCompareSlugs();
|
|
|
|
const entries: SitemapEntry[] = productCompareSlugs.map((slug: string) => {
|
|
return {
|
|
loc: `${baseUrlString}/compare/${slug}`,
|
|
lastmod: timestamp,
|
|
priority: COMPARE_PAGE_CONFIG.priority,
|
|
changefreq: COMPARE_PAGE_CONFIG.changefreq,
|
|
};
|
|
});
|
|
|
|
const xml: string = buildUrlsetXml(entries);
|
|
|
|
compareCache = {
|
|
data: xml,
|
|
generatedAt: OneUptimeDate.getCurrentDate().getTime(),
|
|
};
|
|
|
|
return xml;
|
|
}
|
|
|
|
// Generate sitemap for blog tags (paginated)
|
|
export async function generateTagsSitemapXml(page: number): Promise<string> {
|
|
const cachedTags: CachedData<string> | undefined = tagsCaches.get(page);
|
|
if (isCacheValid(cachedTags)) {
|
|
return cachedTags!.data;
|
|
}
|
|
|
|
const baseUrl: URL = await BlogPostUtil.getHomeUrl();
|
|
const baseUrlString: string = baseUrl.toString().replace(/\/$/, "");
|
|
const timestamp: string = OneUptimeDate.getCurrentDate().toISOString();
|
|
|
|
const allTags: string[] = await BlogPostUtil.getTags();
|
|
|
|
// Calculate slice for this page (1-indexed)
|
|
const startIndex: number = (page - 1) * TAGS_PER_SITEMAP;
|
|
const endIndex: number = startIndex + TAGS_PER_SITEMAP;
|
|
const tagsForPage: string[] = allTags.slice(startIndex, endIndex);
|
|
|
|
const entries: SitemapEntry[] = tagsForPage.map((tag: string) => {
|
|
const tagSlug: string = encodeURIComponent(
|
|
tag.toLowerCase().replace(/\s+/g, "-").trim(),
|
|
);
|
|
return {
|
|
loc: `${baseUrlString}/blog/tag/${tagSlug}`,
|
|
lastmod: timestamp,
|
|
priority: BLOG_TAG_CONFIG.priority,
|
|
changefreq: BLOG_TAG_CONFIG.changefreq,
|
|
};
|
|
});
|
|
|
|
const xml: string = buildUrlsetXml(entries);
|
|
|
|
tagsCaches.set(page, {
|
|
data: xml,
|
|
generatedAt: OneUptimeDate.getCurrentDate().getTime(),
|
|
});
|
|
|
|
return xml;
|
|
}
|
|
|
|
// Generate sitemap for blog posts (paginated)
|
|
export async function generateBlogSitemapXml(page: number): Promise<string> {
|
|
const cachedBlog: CachedData<string> | undefined = blogCaches.get(page);
|
|
if (isCacheValid(cachedBlog)) {
|
|
return cachedBlog!.data;
|
|
}
|
|
|
|
const baseUrl: URL = await BlogPostUtil.getHomeUrl();
|
|
const baseUrlString: string = baseUrl.toString().replace(/\/$/, "");
|
|
|
|
const blogPosts: Array<BlogPostHeader> = await BlogPostUtil.getBlogPostList();
|
|
|
|
// Calculate slice for this page (1-indexed)
|
|
const startIndex: number = (page - 1) * BLOG_POSTS_PER_SITEMAP;
|
|
const endIndex: number = startIndex + BLOG_POSTS_PER_SITEMAP;
|
|
const postsForPage: Array<BlogPostHeader> = blogPosts.slice(
|
|
startIndex,
|
|
endIndex,
|
|
);
|
|
|
|
const entries: SitemapEntry[] = postsForPage.map((post: BlogPostHeader) => {
|
|
const loc: string = post.blogUrl.startsWith("http")
|
|
? post.blogUrl
|
|
: `${baseUrlString}${post.blogUrl.startsWith("/") ? post.blogUrl : `/${post.blogUrl}`}`;
|
|
|
|
return {
|
|
loc,
|
|
lastmod: new Date(post.postDate).toISOString(),
|
|
priority: BLOG_POST_CONFIG.priority,
|
|
changefreq: BLOG_POST_CONFIG.changefreq,
|
|
};
|
|
});
|
|
|
|
const xml: string = buildUrlsetXml(entries);
|
|
|
|
blogCaches.set(page, {
|
|
data: xml,
|
|
generatedAt: OneUptimeDate.getCurrentDate().getTime(),
|
|
});
|
|
|
|
return xml;
|
|
}
|
|
|
|
/*
|
|
* Legacy function for backwards compatibility (generates single sitemap)
|
|
* This is kept in case any other code references it
|
|
*/
|
|
export const generateSitemapXml: () => Promise<string> =
|
|
async (): Promise<string> => {
|
|
// Redirect to sitemap index for backwards compatibility
|
|
return generateSitemapIndexXml();
|
|
};
|
|
|
|
export default generateSitemapXml;
|