blog / src /server /services /seoService.ts
hadadrjt's picture
blog: Bump to 0.0.6 version.
6c25065
//
// SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
// SPDX-License-Identifier: Apache-2.0
//
import { readFile, access } from "fs/promises";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { siteConfig } from "../../shared/config.js";
import { getAllPosts, getPostBySlug } from "./postService.js";
import type { Post, PageMetaConfig, SitemapEntry } from "../../shared/types.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let indexHtmlTemplate: string | null = null;
let templateLoadAttempted = false;
const INDEX_HTML_SEARCH_PATHS: string[] = [
join(__dirname, `../../${siteConfig.files.clientDirectoryName}/${siteConfig.files.indexHtmlFileName}`),
join(__dirname, `../${siteConfig.files.clientDirectoryName}/${siteConfig.files.indexHtmlFileName}`),
join(__dirname, `../../../${siteConfig.files.clientDirectoryName}/${siteConfig.files.indexHtmlFileName}`),
join(process.cwd(), `dist/${siteConfig.files.clientDirectoryName}/${siteConfig.files.indexHtmlFileName}`),
join(process.cwd(), `${siteConfig.files.clientDirectoryName}/${siteConfig.files.indexHtmlFileName}`),
];
const escapeHtml = (value: unknown): string => {
if (value === null || value === undefined) {
return "";
}
const text = String(value);
if (text.length === 0) {
return "";
}
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
const formatKeywords = (keywords: string[]): string => {
if (!keywords || keywords.length === 0) {
return "";
}
return keywords
.map((keyword) => keyword.trim().toLowerCase())
.filter((keyword) => keyword.length > 0)
.filter((keyword, index, self) => self.indexOf(keyword) === index)
.slice(0, siteConfig.seo.maxKeywords)
.join(", ");
};
const mergeKeywords = (postKeywords: string[] | undefined): string => {
const defaultKeywords = siteConfig.keywords || [];
const articleKeywords = postKeywords || [];
return formatKeywords([...articleKeywords, ...defaultKeywords]);
};
const findIndexHtml = async (): Promise<string | null> => {
for (const testPath of INDEX_HTML_SEARCH_PATHS) {
try {
await access(testPath);
console.log(siteConfig.logs.foundIndexHtml(testPath));
return testPath;
} catch {
continue;
}
}
return null;
};
const toDateString = (value: unknown): string => {
if (value === null || value === undefined) {
return "";
}
if (value instanceof Date) {
return value.toISOString();
}
return String(value);
};
const buildMetaTagsHtml = (config: PageMetaConfig): string => {
const tags: string[] = [
`<title>${escapeHtml(config.title)}</title>`,
`<meta name="description" content="${escapeHtml(config.description)}">`,
`<meta name="keywords" content="${escapeHtml(config.keywords)}">`,
`<meta name="author" content="${escapeHtml(config.author)}">`,
`<meta name="robots" content="${escapeHtml(config.robots)}">`,
`<meta name="google-site-verification" content="${siteConfig.seo.googleVerification}">`,
`<meta name="msvalidate.01" content="${siteConfig.seo.bingVerification}">`,
`<meta property="og:type" content="${escapeHtml(config.ogType)}">`,
];
if (config.ogUrl) {
tags.push(`<meta property="og:url" content="${escapeHtml(config.ogUrl)}">`);
}
tags.push(
`<meta property="og:title" content="${escapeHtml(config.title)}">`,
`<meta property="og:description" content="${escapeHtml(config.description)}">`
);
if (config.ogImage) {
tags.push(`<meta property="og:image" content="${escapeHtml(config.ogImage)}">`);
}
if (config.ogSiteName) {
tags.push(`<meta property="og:site_name" content="${escapeHtml(config.ogSiteName)}">`);
}
if (config.articlePublishedTime) {
tags.push(`<meta property="article:published_time" content="${escapeHtml(config.articlePublishedTime)}">`);
}
if (config.articleAuthor) {
tags.push(`<meta property="article:author" content="${escapeHtml(config.articleAuthor)}">`);
}
if (config.articleTags) {
for (const tag of config.articleTags) {
tags.push(`<meta property="article:tag" content="${escapeHtml(tag)}">`);
}
}
tags.push(`<meta name="twitter:card" content="${siteConfig.seo.twitterCardType}">`);
if (config.twitterUrl) {
tags.push(`<meta name="twitter:url" content="${escapeHtml(config.twitterUrl)}">`);
}
tags.push(
`<meta name="twitter:title" content="${escapeHtml(config.title)}">`,
`<meta name="twitter:description" content="${escapeHtml(config.description)}">`
);
if (config.twitterImage) {
tags.push(`<meta name="twitter:image" content="${escapeHtml(config.twitterImage)}">`);
}
return "\n " + tags.join("\n ") + "\n ";
};
const injectMetaTags = (html: string, metaTags: string): string => {
if (html.includes(siteConfig.seo.metaTagPlaceholder)) {
return html.replace(siteConfig.seo.metaTagPlaceholder, metaTags);
}
if (html.includes("<head>")) {
return html.replace("<head>", `<head>${metaTags}`);
}
if (html.includes("</head>")) {
return html.replace("</head>", `${metaTags}</head>`);
}
console.warn(siteConfig.errors.couldNotFindMetaTagInsertionPoint);
return html;
};
const buildSitemapEntry = (entry: SitemapEntry): string => {
return ` <url>
<loc>${escapeHtml(entry.location)}</loc>
<lastmod>${entry.lastModified}</lastmod>
<changefreq>${entry.changeFrequency}</changefreq>
<priority>${entry.priority}</priority>
</url>`;
};
export const loadIndexHtmlTemplate = async (): Promise<string> => {
if (indexHtmlTemplate !== null) {
return indexHtmlTemplate;
}
if (templateLoadAttempted) {
return "";
}
templateLoadAttempted = true;
const indexPath = await findIndexHtml();
if (!indexPath) {
console.error(siteConfig.errors.couldNotFindIndexHtml);
console.error(siteConfig.errors.searchedPaths, INDEX_HTML_SEARCH_PATHS);
indexHtmlTemplate = "";
return "";
}
try {
const content = await readFile(indexPath, "utf-8");
indexHtmlTemplate = content;
return content;
} catch (error) {
console.error(siteConfig.errors.readingIndexHtml(indexPath, error));
indexHtmlTemplate = "";
return "";
}
};
export const generateMetaTags = (post: Post): string => {
const postUrl = `${siteConfig.siteUrl || ""}/post/${post.slug}`;
const postImage = post.frontMatter.image || `${siteConfig.siteUrl || ""}/assets/images/profile.png`;
const postAuthor = post.frontMatter.author || siteConfig.author.name;
const postDate = toDateString(post.frontMatter.date);
return buildMetaTagsHtml({
title: `${post.frontMatter.title} - ${siteConfig.title}`,
description: post.frontMatter.description || siteConfig.description,
keywords: mergeKeywords(post.frontMatter.tags),
author: postAuthor,
robots: siteConfig.seo.articleRobots,
ogType: "article",
ogUrl: postUrl,
ogImage: postImage,
ogSiteName: siteConfig.name,
articlePublishedTime: postDate,
articleAuthor: postAuthor,
articleTags: post.frontMatter.tags,
twitterUrl: postUrl,
twitterImage: postImage,
});
};
export const generateDefaultMetaTags = (): string => {
return buildMetaTagsHtml({
title: siteConfig.title,
description: siteConfig.description,
keywords: formatKeywords(siteConfig.keywords),
author: siteConfig.author.name,
robots: siteConfig.seo.defaultRobots,
ogType: "website",
});
};
export const renderPostPage = async (slug: string): Promise<string> => {
try {
const template = await loadIndexHtmlTemplate();
if (!template) {
throw new Error(siteConfig.errors.templateNotLoaded);
}
if (!slug) {
return injectMetaTags(template, generateDefaultMetaTags());
}
const post = getPostBySlug(slug);
if (!post) {
console.warn(siteConfig.errors.postNotFoundForSlug(slug));
return injectMetaTags(template, generateDefaultMetaTags());
}
return injectMetaTags(template, generateMetaTags(post));
} catch (error) {
console.error(siteConfig.errors.renderingPostPage(error));
throw error;
}
};
export const generateRobotsTxt = (baseUrl: string): string => {
return `User-agent: *
Allow: /
Sitemap: ${baseUrl}/sitemap.xml
`;
};
export const generateSitemapXml = async (baseUrl: string): Promise<string> => {
const posts = getAllPosts();
const currentDate = new Date().toISOString().split("T")[0];
const entries: SitemapEntry[] = [
{
location: `${baseUrl}/`,
lastModified: currentDate,
changeFrequency: siteConfig.seo.sitemapChangeFrequency.home,
priority: siteConfig.seo.sitemapPriority.home,
},
];
for (const post of posts) {
const rawDate = toDateString(post.frontMatter.date);
const formattedDate = rawDate ? new Date(rawDate).toISOString().split("T")[0] : currentDate;
entries.push({
location: `${baseUrl}/post/${post.slug}`,
lastModified: formattedDate,
changeFrequency: siteConfig.seo.sitemapChangeFrequency.post,
priority: siteConfig.seo.sitemapPriority.post,
});
}
const urlEntries = entries.map(buildSitemapEntry).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlEntries}
</urlset>`;
};