UNPKG

@qalisa/vike-plugin-sitemap

Version:

An unofficial Vike plugin to automagically generate sitemap and robots.txt files

266 lines (265 loc) 9.06 kB
import { promises as fs } from 'fs'; import { resolve, join } from 'path'; /** * Helper functions to handle duplicate detection and reporting */ const DuplicateHandler = { /** * Group an array into subarrays based on a key. * Returns an array of arrays (not a Record). */ groupBy(array, keyGetter) { const groups = array.reduce((result, item) => { const key = keyGetter(item); if (!result[key]) { result[key] = []; } result[key].push(item); return result; }, {}); return Object.values(groups); }, /** * Resolve a duplicate URL detection */ resolveDuplicate(options, clashingPaths) { // const getResolutionMessage = (() => { switch (options.clashingPathsResolution) { case 'ignore': return `Still adding to sitemap.`; case 'remove': return `Removed from sitemap.`; case 'error': return "Cancelling."; } }); const getMessage = () => `⚠️ Sitemap : Routes definition clash found -> [${clashingPaths.map(e => e.shortPath).join(', ')}]: ${getResolutionMessage()}`; // let message = ""; if (options.debug.printRoutes) { message = getMessage(); console.warn(message); } // switch (options.clashingPathsResolution) { case 'ignore': return [clashingPaths[0]]; case 'remove': return []; case 'error': throw message ?? getMessage(); } }, /** * Reports a duplicate custom URL detection */ reportCustomDuplicate(loc, existingPath) { console.warn(`⚠️ Sitemap : Duplicate custom URL "${loc}" - route already defined (custom entry == ${existingPath})`); } }; /** * Helper functions to create sitemap entries */ const EntryBuilder = { /** * Gets the last modified date for a file */ async getLastModifiedDate(filePath, formatDate) { try { const stat = await fs.stat(filePath); return formatDate(stat.mtime); } catch (err) { console.warn(`Could not get last modified date for ${filePath}:`, err); return undefined; } } }; function _resolver(rootDir) { return (dir) => { // eslint-disable-next-line deprecation/deprecation (relative to #7) const path = dir.parentPath ?? dir.path; const shortPath = path.substring(rootDir.length); const spSegments = shortPath.split("/").filter(Boolean); const resolve = () => { // const segmentsOut = []; // for (const segment of spSegments) { // if (segment == "index") continue; if (segment == "pages") continue; if (segment.startsWith("(") && segment.endsWith(")")) continue; // if (segment.startsWith("_")) return { rejectReason: "specialFolder" }; if (segment.startsWith("@")) return { rejectReason: "SSGUnhandled" }; // segmentsOut.push(segment); } // const out = segmentsOut.join("/") + "/"; // return segmentsOut.length ? "/" + out : out; }; // return { shortPath, spSegments, resolution: resolve() }; }; } /** */ async function getResolvedPages(rootDir) { // const isIndexable = (dir) => dir.name.startsWith("+Page."); // const items = await fs.readdir(rootDir, { withFileTypes: true, recursive: true }); // return items .filter(isIndexable) .map(_resolver(rootDir)); } /** * Sorts sitemap entries according to the requested priority: * 1. Index routes first * 2. Routes with parameters (like @id) * 3. Other routes in ascending alphabetical order */ const resolvedPagesSorter = (a, b) => { const pathA = a.spSegments; const pathB = b.spSegments; // Compare each path segment const minLength = Math.min(pathA.length, pathB.length); for (let i = 0; i < minLength; i++) { const segmentA = pathA[i]; const segmentB = pathB[i]; // If it's the last segment of path A and it's an index page, A has priority if (i === pathA.length - 1 && segmentA === 'index') { return -1; } // If it's the last segment of path B and it's an index page, B has priority if (i === pathB.length - 1 && segmentB === 'index') { return 1; } // If A is a parameter (starts with @) and B is not, A has priority after indexes if (segmentA.startsWith('@') && !segmentB.startsWith('@')) { return -1; } // If B is a parameter (starts with @) and A is not, B has priority after indexes if (segmentB.startsWith('@') && !segmentA.startsWith('@')) { return 1; } // If both are parameters or neither is a parameter, compare alphabetically if (segmentA !== segmentB) { return segmentA.localeCompare(segmentB); } } // If all compared segments are equal, the shorter path has priority return pathA.length - pathB.length; }; /** */ async function getSitemapEntries(options, dir) { // const resolvedWithoutRejection = (await getResolvedPages(dir)) .sort(resolvedPagesSorter) .filter(({ shortPath, resolution }) => { // if (typeof resolution === "string") return true; // if (options.debug.printIgnored) { switch (resolution.rejectReason) { case "SSGUnhandled": { console.warn(`⚠️ Sitemap : Cannot generate SSG path yet for: ${shortPath}`); } default: { } } } // return false; }); // const withoutDuplicates = DuplicateHandler .groupBy(resolvedWithoutRejection, (e) => e.resolution) .flatMap((e) => { // if (e.length > 1) { return DuplicateHandler.resolveDuplicate(options, e); } // return e; }); // const entries = withoutDuplicates.map(async ({ shortPath, resolution }) => { // const lastmod = await EntryBuilder.getLastModifiedDate(dir + shortPath, options.formatDate); // return { lastmod, loc: options.baseUrl + resolution, changefreq: options.defaultChangefreq, priority: options.defaultPriority, }; }); // return Promise.all(entries); } /** * Generate sitemap XML content (returns string instead of writing to disk) */ export async function generateSitemapContent(options) { const { pagesDir, customEntries, debug, } = options; const resolvedPagesDir = resolve(process.cwd(), pagesDir); const existingLocations = new Map(); const entries = await getSitemapEntries(options, resolvedPagesDir); // Check for duplicate custom URLs for (const entry of customEntries) { if (existingLocations.has(entry.loc)) { DuplicateHandler.reportCustomDuplicate(entry.loc, existingLocations.get(entry.loc) || ''); } else { existingLocations.set(entry.loc, 'custom entry'); entries.push(entry); } } if (debug.printRoutes) { entries.forEach((e) => console.log(`✅ Sitemap : route "${e.loc}"`)); } // Generate XML entries const xmlEntries = entries.map((entry) => { let xml = ' <url>\n'; xml += ` <loc>${entry.loc}</loc>\n`; if (entry.lastmod) xml += ` <lastmod>${entry.lastmod}</lastmod>\n`; if (entry.changefreq) xml += ` <changefreq>${entry.changefreq}</changefreq>\n`; if (entry.priority !== undefined) xml += ` <priority>${entry.priority}</priority>\n`; xml += ' </url>'; return xml; }); // Return the complete XML document return `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> ${xmlEntries.join('\n')} </urlset>`; } /** * Write sitemap to disk (for production) */ export async function writeSitemapToDisk(options, viteOutdir) { const { filename, outputDir } = options; const resolvedOutputDir = resolve(process.cwd(), viteOutdir, outputDir); await fs.mkdir(resolvedOutputDir, { recursive: true }); const sitemapContent = await generateSitemapContent(options); const writeTo = join(resolvedOutputDir, filename); await fs.writeFile(writeTo, sitemapContent, 'utf8'); console.log(`✅ Sitemap generated at "${writeTo}" !`); }