| |
| |
| |
| |
|
|
| 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, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """) |
| .replace(/'/g, "'"); |
| }; |
|
|
| 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>`; |
| }; |