|
| 1 | +/** |
| 2 | + * Docusaurus plugin to generate per-product sitemaps |
| 3 | + * |
| 4 | + * This plugin hooks into the build lifecycle and generates individual |
| 5 | + * sitemap.xml files for each product documentation section. |
| 6 | + */ |
| 7 | + |
| 8 | +import fs from 'node:fs/promises'; |
| 9 | +import path from 'node:path'; |
| 10 | +import { normalizeUrl } from '@docusaurus/utils'; |
| 11 | + |
| 12 | +/** |
| 13 | + * Generate XML sitemap content |
| 14 | + */ |
| 15 | +function generateSitemapXML(urls, productLabel) { |
| 16 | + const urlEntries = urls |
| 17 | + .map(item => { |
| 18 | + return ` <url> |
| 19 | + <loc>${item.url}</loc> |
| 20 | + <lastmod>${item.lastmod || new Date().toISOString().split('T')[0]}</lastmod> |
| 21 | + <changefreq>${item.changefreq || 'weekly'}</changefreq> |
| 22 | + <priority>${item.priority || 0.5}</priority> |
| 23 | + </url>`; |
| 24 | + }) |
| 25 | + .join('\n'); |
| 26 | + |
| 27 | + return `<?xml version="1.0" encoding="UTF-8"?> |
| 28 | +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" |
| 29 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
| 30 | + xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 |
| 31 | + http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> |
| 32 | + <!-- Generated: ${new Date().toISOString()} --> |
| 33 | + <!-- Product: ${productLabel} --> |
| 34 | + <!-- Total URLs: ${urls.length} --> |
| 35 | +${urlEntries} |
| 36 | +</urlset> |
| 37 | +`; |
| 38 | +} |
| 39 | + |
| 40 | +/** |
| 41 | + * Generate a sitemap index file |
| 42 | + */ |
| 43 | +function generateSitemapIndex(products, baseUrl) { |
| 44 | + const sitemaps = products |
| 45 | + .map(product => { |
| 46 | + return ` <sitemap> |
| 47 | + <loc>${normalizeUrl([baseUrl, 'docs', product.id, 'sitemap.xml'])}</loc> |
| 48 | + <lastmod>${new Date().toISOString().split('T')[0]}</lastmod> |
| 49 | + </sitemap>`; |
| 50 | + }) |
| 51 | + .join('\n'); |
| 52 | + |
| 53 | + return `<?xml version="1.0" encoding="UTF-8"?> |
| 54 | +<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> |
| 55 | + <!-- Generated: ${new Date().toISOString()} --> |
| 56 | + <!-- Total Product Sitemaps: ${products.length} --> |
| 57 | +${sitemaps} |
| 58 | +</sitemapindex> |
| 59 | +`; |
| 60 | +} |
| 61 | + |
| 62 | +/** |
| 63 | + * Product Sitemaps Plugin |
| 64 | + * |
| 65 | + * @param {import('@docusaurus/types').LoadContext} context |
| 66 | + * @param {object} options |
| 67 | + * @returns {import('@docusaurus/types').Plugin} |
| 68 | + */ |
| 69 | +export default function productSitemapsPlugin(context, options) { |
| 70 | + const { siteConfig, generatedFilesDir } = context; |
| 71 | + const { products = [] } = options; |
| 72 | + |
| 73 | + return { |
| 74 | + name: 'product-sitemaps', |
| 75 | + |
| 76 | + async postBuild({ routesPaths, outDir, routes }) { |
| 77 | + if (!products || products.length === 0) { |
| 78 | + return; |
| 79 | + } |
| 80 | + |
| 81 | + const baseUrl = normalizeUrl([siteConfig.url, siteConfig.baseUrl]); |
| 82 | + const productsWithSitemaps = []; |
| 83 | + |
| 84 | + console.log('\n🗺️ Generating per-product sitemaps...'); |
| 85 | + |
| 86 | + // Generate sitemap for each product |
| 87 | + for (const product of products) { |
| 88 | + const productId = product.id; |
| 89 | + const productLabel = product.label; |
| 90 | + |
| 91 | + // Filter routes that belong to this product |
| 92 | + const productRoutes = routesPaths.filter(route => { |
| 93 | + // Match routes like /docs/{productId}/ or /docs/{productId}/... |
| 94 | + return route.startsWith(`/docs/${productId}/`) || route === `/docs/${productId}`; |
| 95 | + }); |
| 96 | + |
| 97 | + if (productRoutes.length === 0) { |
| 98 | + console.log(` ⚠️ Skipping ${productId} - no routes found`); |
| 99 | + continue; |
| 100 | + } |
| 101 | + |
| 102 | + // Convert routes to sitemap items |
| 103 | + const sitemapItems = productRoutes.map(route => { |
| 104 | + const url = normalizeUrl([baseUrl, route]); |
| 105 | + |
| 106 | + // Determine priority based on route depth |
| 107 | + let priority = 0.5; |
| 108 | + let changefreq = 'monthly'; |
| 109 | + |
| 110 | + if (route === `/docs/${productId}` || route === `/docs/${productId}/`) { |
| 111 | + // Product home page |
| 112 | + priority = 0.9; |
| 113 | + changefreq = 'weekly'; |
| 114 | + } else if (route.match(/\/docs\/[^/]+\/[^/]+\/?$/)) { |
| 115 | + // Top-level pages (one level deep) |
| 116 | + priority = 0.7; |
| 117 | + changefreq = 'weekly'; |
| 118 | + } else if (route.includes('/actions/') || route.includes('/filters/')) { |
| 119 | + // Hook documentation |
| 120 | + priority = 0.6; |
| 121 | + changefreq = 'monthly'; |
| 122 | + } |
| 123 | + |
| 124 | + return { |
| 125 | + url, |
| 126 | + lastmod: new Date().toISOString().split('T')[0], |
| 127 | + changefreq, |
| 128 | + priority, |
| 129 | + }; |
| 130 | + }); |
| 131 | + |
| 132 | + // Generate sitemap XML |
| 133 | + const sitemapXML = generateSitemapXML(sitemapItems, productLabel); |
| 134 | + |
| 135 | + // Write to output directory at /docs/{productId}/sitemap.xml |
| 136 | + const sitemapPath = path.join(outDir, 'docs', productId, 'sitemap.xml'); |
| 137 | + await fs.mkdir(path.dirname(sitemapPath), { recursive: true }); |
| 138 | + await fs.writeFile(sitemapPath, sitemapXML, 'utf-8'); |
| 139 | + |
| 140 | + console.log(` ✅ Generated ${productId} sitemap (${sitemapItems.length} URLs)`); |
| 141 | + productsWithSitemaps.push(product); |
| 142 | + } |
| 143 | + |
| 144 | + // Generate sitemap index |
| 145 | + if (productsWithSitemaps.length > 0) { |
| 146 | + const sitemapIndex = generateSitemapIndex(productsWithSitemaps, baseUrl); |
| 147 | + const indexPath = path.join(outDir, 'sitemap-products.xml'); |
| 148 | + await fs.writeFile(indexPath, sitemapIndex, 'utf-8'); |
| 149 | + console.log(` ✅ Generated sitemap index (${productsWithSitemaps.length} products)`); |
| 150 | + } |
| 151 | + |
| 152 | + console.log('✅ Product sitemaps generation complete\n'); |
| 153 | + }, |
| 154 | + }; |
| 155 | +} |
0 commit comments