import * as plugins from './smartsitemap.plugins.js'; import type * as interfaces from './interfaces/index.js'; // Sitemap XML namespace constants const NS_SITEMAP = 'http://www.sitemaps.org/schemas/sitemap/0.9'; const NS_IMAGE = 'http://www.google.com/schemas/sitemap-image/1.1'; const NS_VIDEO = 'http://www.google.com/schemas/sitemap-video/1.1'; const NS_NEWS = 'http://www.google.com/schemas/sitemap-news/0.9'; const NS_XHTML = 'http://www.w3.org/1999/xhtml'; /** * Handles all XML generation for sitemaps. * Supports proper escaping, namespace detection, date formatting, * XSL stylesheet references, and pretty printing. */ export class XmlRenderer { /** * Escape a string for use in XML content. * Handles the 5 XML special characters. */ static escapeXml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Format a date value (Date, ISO string, or Unix timestamp in ms) * to W3C Datetime format suitable for sitemaps. */ static formatDate(date: Date | string | number): string { if (date instanceof Date) { return date.toISOString(); } if (typeof date === 'number') { return new Date(date).toISOString(); } // Already a string — validate it parses const parsed = new Date(date); if (isNaN(parsed.getTime())) { return date; // Return as-is if unparseable } return parsed.toISOString(); } /** * Detect which XML namespaces are needed based on URL entries. */ static detectNamespaces(urls: interfaces.ISitemapUrl[]): Record { const ns: Record = { '@_xmlns': NS_SITEMAP, }; for (const url of urls) { if (url.images && url.images.length > 0) { ns['@_xmlns:image'] = NS_IMAGE; } if (url.videos && url.videos.length > 0) { ns['@_xmlns:video'] = NS_VIDEO; } if (url.news) { ns['@_xmlns:news'] = NS_NEWS; } if (url.alternates && url.alternates.length > 0) { ns['@_xmlns:xhtml'] = NS_XHTML; } } return ns; } /** * Render a URL array to sitemap XML string. */ static renderUrlset(urls: interfaces.ISitemapUrl[], options?: interfaces.ISitemapOptions): string { const namespaces = XmlRenderer.detectNamespaces(urls); const urlElements = urls.map((url) => XmlRenderer.buildUrlElement(url, options)); const xmlObj: any = { urlset: { ...namespaces, url: urlElements, }, }; const smartXml = new plugins.smartxml.SmartXml(); let xml = smartXml.createXmlFromObject(xmlObj); // Insert XSL stylesheet processing instruction if specified if (options?.xslUrl) { xml = XmlRenderer.insertXslInstruction(xml, options.xslUrl); } return xml; } /** * Render a sitemap index XML string. */ static renderIndex(entries: interfaces.ISitemapIndexEntry[], options?: interfaces.ISitemapOptions): string { const sitemapElements = entries.map((entry) => { const el: any = { loc: XmlRenderer.escapeXml(entry.loc), }; if (entry.lastmod != null) { el.lastmod = XmlRenderer.formatDate(entry.lastmod); } return el; }); const xmlObj: any = { sitemapindex: { '@_xmlns': NS_SITEMAP, sitemap: sitemapElements, }, }; const smartXml = new plugins.smartxml.SmartXml(); let xml = smartXml.createXmlFromObject(xmlObj); if (options?.xslUrl) { xml = XmlRenderer.insertXslInstruction(xml, options.xslUrl); } return xml; } /** * Render URLs as plain text (one URL per line). */ static renderTxt(urls: interfaces.ISitemapUrl[]): string { return urls.map((u) => u.loc).join('\n'); } /** * Render URLs as JSON. */ static renderJson(urls: interfaces.ISitemapUrl[]): string { return JSON.stringify(urls, null, 2); } /** * Build a single element object for use with smartxml. */ private static buildUrlElement(url: interfaces.ISitemapUrl, options?: interfaces.ISitemapOptions): any { const el: any = { loc: XmlRenderer.escapeXml(url.loc), }; // lastmod if (url.lastmod != null) { el.lastmod = XmlRenderer.formatDate(url.lastmod); } // changefreq (use default if not specified) const changefreq = url.changefreq ?? options?.defaultChangeFreq; if (changefreq) { el.changefreq = changefreq; } // priority (use default if not specified) const priority = url.priority ?? options?.defaultPriority; if (priority != null) { el.priority = priority.toFixed(1); } // Image extension if (url.images && url.images.length > 0) { el['image:image'] = url.images.map((img) => XmlRenderer.buildImageElement(img)); } // Video extension if (url.videos && url.videos.length > 0) { el['video:video'] = url.videos.map((vid) => XmlRenderer.buildVideoElement(vid)); } // News extension if (url.news) { el['news:news'] = XmlRenderer.buildNewsElement(url.news); } // hreflang alternates if (url.alternates && url.alternates.length > 0) { el['xhtml:link'] = url.alternates.map((alt) => ({ '@_rel': 'alternate', '@_hreflang': alt.hreflang, '@_href': XmlRenderer.escapeXml(alt.href), })); } return el; } /** * Build an element object. */ private static buildImageElement(img: interfaces.ISitemapImage): any { const el: any = { 'image:loc': XmlRenderer.escapeXml(img.loc), }; if (img.caption) { el['image:caption'] = XmlRenderer.escapeXml(img.caption); } if (img.title) { el['image:title'] = XmlRenderer.escapeXml(img.title); } if (img.geoLocation) { el['image:geo_location'] = XmlRenderer.escapeXml(img.geoLocation); } if (img.licenseUrl) { el['image:license'] = XmlRenderer.escapeXml(img.licenseUrl); } return el; } /** * Build a element object. */ private static buildVideoElement(vid: interfaces.ISitemapVideo): any { const el: any = { 'video:thumbnail_loc': XmlRenderer.escapeXml(vid.thumbnailLoc), 'video:title': XmlRenderer.escapeXml(vid.title), 'video:description': XmlRenderer.escapeXml(vid.description), }; if (vid.contentLoc) { el['video:content_loc'] = XmlRenderer.escapeXml(vid.contentLoc); } if (vid.playerLoc) { el['video:player_loc'] = XmlRenderer.escapeXml(vid.playerLoc); } if (vid.duration != null) { el['video:duration'] = vid.duration; } if (vid.rating != null) { el['video:rating'] = vid.rating; } if (vid.viewCount != null) { el['video:view_count'] = vid.viewCount; } if (vid.publicationDate != null) { el['video:publication_date'] = XmlRenderer.formatDate(vid.publicationDate); } if (vid.familyFriendly != null) { el['video:family_friendly'] = vid.familyFriendly ? 'yes' : 'no'; } if (vid.tags && vid.tags.length > 0) { el['video:tag'] = vid.tags; } if (vid.live != null) { el['video:live'] = vid.live ? 'yes' : 'no'; } if (vid.requiresSubscription != null) { el['video:requires_subscription'] = vid.requiresSubscription ? 'yes' : 'no'; } return el; } /** * Build a element object. */ private static buildNewsElement(news: interfaces.ISitemapNews): any { const el: any = { 'news:publication': { 'news:name': XmlRenderer.escapeXml(news.publication.name), 'news:language': news.publication.language, }, 'news:publication_date': XmlRenderer.formatDate(news.publicationDate), 'news:title': XmlRenderer.escapeXml(news.title), }; if (news.keywords) { const kw = Array.isArray(news.keywords) ? news.keywords.join(', ') : news.keywords; el['news:keywords'] = XmlRenderer.escapeXml(kw); } return el; } /** * Insert an XSL stylesheet processing instruction after the XML declaration. */ private static insertXslInstruction(xml: string, xslUrl: string): string { const pi = ``; return xml.replace( '', `\n${pi}`, ); } }