// src/services/sitemapService.js
const path = require("path");
const fs = require("fs").promises;
const { getAllPosts } = require("../utils/postFileUtils");
const {
STATIC_SITEMAP_PATH,
PAGES_PATH,
POSTS_PATH,
DEFAULT_CHANGEFREQ,
DEFAULT_PRIORITY,
BLOG_POST_CHANGEFREQ,
BLOG_POST_PRIORITY,
} = require("../constants/sitemapConstants");
const matter = require("gray-matter");
class SitemapService {
constructor() {
this.staticSitemapPath = path.resolve(__dirname, STATIC_SITEMAP_PATH);
this.pagesPath = path.join(__dirname, PAGES_PATH);
this.postsPath = path.join(__dirname, POSTS_PATH);
}
async getStaticPages() {
try {
const data = await fs.readFile(this.staticSitemapPath, "utf-8");
return JSON.parse(data);
} catch {
console.warn("Could not load static sitemap.json, using empty array");
return [];
}
}
async getStaticPages() {
try {
const filenames = await fs.readdir(this.pagesPath);
const pages = [];
for (const file of filenames) {
const fullPath = path.join(this.pagesPath, file);
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) continue;
const raw = await fs.readFile(fullPath, "utf8");
const { data: frontmatter } = matter(raw);
if (!frontmatter.published) continue;
pages.push({
loc: `/${frontmatter.slug || file.replace(/\.(md|mdx|handlebars)$/, "")}`,
title: frontmatter.title || "",
lastmod: frontmatter.updated || frontmatter.date || null,
changefreq: "monthly",
priority: 0.7,
});
}
return pages;
} catch (err) {
console.warn("Failed to load static pages:", err);
return [];
}
}
async getBlogPostUrls() {
const allPosts = await getAllPosts(this.postsPath);
return allPosts.map((post) => ({
loc: `/blog/${post.year}/${post.month}/${post.slug}`,
lastmod: post.date
? new Date(post.date).toISOString().split("T")[0]
: null,
changefreq: BLOG_POST_CHANGEFREQ,
priority: BLOG_POST_PRIORITY,
}));
}
injectPlaceholder(tree, key, items) {
for (const node of tree) {
if (Array.isArray(node.children)) {
const index = node.children.findIndex(
(child) => child.loc === `#inject:${key}`
);
if (index !== -1) {
const placeholder = node.children[index];
node.children.splice(index, 1, ...items);
return true;
}
if (this.injectPlaceholder(node.children, key, items)) {
return true;
}
}
}
return false;
}
async _loadStaticLayout() {
try {
const data = await fs.readFile(this.staticSitemapPath, "utf-8");
return JSON.parse(data);
} catch {
console.warn("Could not load static sitemap.json, using empty array");
return [];
}
}
async getCompleteSitemap() {
const [staticPagesJsonTree, staticPages, blogUrls] = await Promise.all([
this._loadStaticLayout(),
this.getStaticPages(),
this.getBlogPostUrls(),
]);
const pageItems = staticPages.map((page) => ({
loc: page.loc,
title: page.title,
lastmod: page.lastmod,
changefreq: page.changefreq,
priority: page.priority,
}));
const blogPosts = blogUrls.map((url) => ({
loc: url.loc,
title: url.loc.split("/").pop().replace(/-/g, " "),
lastmod: url.lastmod,
changefreq: url.changefreq,
priority: url.priority,
}));
this.injectPlaceholder(staticPagesJsonTree, "pages", pageItems);
this.injectPlaceholder(staticPagesJsonTree, "blog-posts", blogPosts);
return staticPagesJsonTree;
}
async getAllUrls() {
const sitemap = await this.getCompleteSitemap();
return this.flatten(sitemap);
}
flatten(entries, out = []) {
for (const entry of entries) {
if (entry.loc) {
out.push({
loc: entry.loc,
lastmod: entry.lastmod,
changefreq: entry.changefreq || DEFAULT_CHANGEFREQ,
priority: entry.priority || DEFAULT_PRIORITY,
});
}
if (Array.isArray(entry.children)) {
this.flatten(entry.children, out);
}
}
return out;
}
}
module.exports = new SitemapService();