|
| 1 | +const escapeXml = (value) => |
| 2 | + String(value) |
| 3 | + .replaceAll("&", "&") |
| 4 | + .replaceAll("<", "<") |
| 5 | + .replaceAll(">", ">") |
| 6 | + .replaceAll('"', """) |
| 7 | + .replaceAll("'", "'"); |
| 8 | + |
| 9 | +const canonicalUrl = (siteOrigin, pathname) => |
| 10 | + new URL(pathname.replace(/^\//, ""), `${siteOrigin.replace(/\/$/, "")}/`).href; |
| 11 | + |
| 12 | +const escapeHtml = (value) => |
| 13 | + String(value) |
| 14 | + .replaceAll("&", "&") |
| 15 | + .replaceAll("<", "<") |
| 16 | + .replaceAll(">", ">") |
| 17 | + .replaceAll('"', """); |
| 18 | + |
| 19 | +const replaceRequired = (html, pattern, replacement, label) => { |
| 20 | + if (!pattern.test(html)) { |
| 21 | + throw new Error(`Unable to find ${label} in the Vite HTML template.`); |
| 22 | + } |
| 23 | + return html.replace(pattern, replacement); |
| 24 | +}; |
| 25 | + |
| 26 | +const jsonForHtml = (value) => JSON.stringify(value).replaceAll("<", "\\u003c"); |
| 27 | + |
| 28 | +export const buildSitemapXml = ({ pages, siteOrigin, lastModified }) => { |
| 29 | + const urls = pages |
| 30 | + .map( |
| 31 | + (page) => ` <url> |
| 32 | + <loc>${escapeXml(canonicalUrl(siteOrigin, page.path))}</loc> |
| 33 | + <lastmod>${escapeXml(lastModified)}</lastmod> |
| 34 | + </url>`, |
| 35 | + ) |
| 36 | + .join("\n"); |
| 37 | + |
| 38 | + return `<?xml version="1.0" encoding="UTF-8"?> |
| 39 | +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> |
| 40 | +${urls} |
| 41 | +</urlset> |
| 42 | +`; |
| 43 | +}; |
| 44 | + |
| 45 | +export const buildRobotsTxt = ({ noindex, siteOrigin }) => { |
| 46 | + // Let crawlers fetch staging pages so they can observe the page-level noindex. |
| 47 | + // A robots.txt file under a GitHub project-pages subpath is not host-wide policy. |
| 48 | + if (noindex) return "User-agent: *\nAllow: /\n"; |
| 49 | + const sitemapUrl = canonicalUrl(siteOrigin, "/sitemap.xml"); |
| 50 | + return `User-agent: *\nAllow: /\n\nSitemap: ${sitemapUrl}\n`; |
| 51 | +}; |
| 52 | + |
| 53 | +export const buildPageHtml = ({ |
| 54 | + template, |
| 55 | + appHtml, |
| 56 | + page, |
| 57 | + siteIdentity, |
| 58 | + siteOrigin, |
| 59 | + noindex, |
| 60 | +}) => { |
| 61 | + const url = canonicalUrl(siteOrigin, page.path); |
| 62 | + const title = escapeHtml(page.title); |
| 63 | + const description = escapeHtml(page.description); |
| 64 | + const structuredData = [ |
| 65 | + { |
| 66 | + "@context": "https://schema.org", |
| 67 | + "@type": "EducationalOrganization", |
| 68 | + name: siteIdentity.name, |
| 69 | + alternateName: siteIdentity.alternateName, |
| 70 | + url: canonicalUrl(siteOrigin, "/"), |
| 71 | + logo: canonicalUrl(siteOrigin, siteIdentity.logoPath), |
| 72 | + sameAs: siteIdentity.sameAs, |
| 73 | + }, |
| 74 | + { |
| 75 | + "@context": "https://schema.org", |
| 76 | + "@type": "WebPage", |
| 77 | + name: page.title, |
| 78 | + description: page.description, |
| 79 | + url, |
| 80 | + isPartOf: { |
| 81 | + "@type": "WebSite", |
| 82 | + name: siteIdentity.name, |
| 83 | + url: canonicalUrl(siteOrigin, "/"), |
| 84 | + }, |
| 85 | + }, |
| 86 | + ]; |
| 87 | + |
| 88 | + let html = replaceRequired( |
| 89 | + template, |
| 90 | + /<title>[^<]*<\/title>/i, |
| 91 | + `<title>${title}</title>`, |
| 92 | + "title", |
| 93 | + ); |
| 94 | + html = replaceRequired( |
| 95 | + html, |
| 96 | + /<meta\s+name="description"\s+content="[^"]*"\s*\/?\s*>/i, |
| 97 | + `<meta name="description" content="${description}" />`, |
| 98 | + "meta description", |
| 99 | + ); |
| 100 | + html = replaceRequired( |
| 101 | + html, |
| 102 | + /<link\s+rel="canonical"\s+href="[^"]*"\s*\/?\s*>/i, |
| 103 | + `<link rel="canonical" href="${escapeHtml(url)}" />`, |
| 104 | + "canonical link", |
| 105 | + ); |
| 106 | + |
| 107 | + const replacements = [ |
| 108 | + ["og:title", title], |
| 109 | + ["og:description", description], |
| 110 | + ["og:url", escapeHtml(url)], |
| 111 | + ]; |
| 112 | + for (const [property, content] of replacements) { |
| 113 | + html = replaceRequired( |
| 114 | + html, |
| 115 | + new RegExp(`<meta\\s+property="${property}"\\s+content="[^"]*"\\s*\\/?\\s*>`, "i"), |
| 116 | + `<meta property="${property}" content="${content}" />`, |
| 117 | + property, |
| 118 | + ); |
| 119 | + } |
| 120 | + |
| 121 | + const twitterReplacements = [ |
| 122 | + ["twitter:title", title], |
| 123 | + ["twitter:description", description], |
| 124 | + ]; |
| 125 | + for (const [name, content] of twitterReplacements) { |
| 126 | + html = replaceRequired( |
| 127 | + html, |
| 128 | + new RegExp(`<meta\\s+name="${name}"\\s+content="[^"]*"\\s*\\/?\\s*>`, "i"), |
| 129 | + `<meta name="${name}" content="${content}" />`, |
| 130 | + name, |
| 131 | + ); |
| 132 | + } |
| 133 | + |
| 134 | + html = html.replace(/\s*<meta\s+name="robots"[^>]*>/i, ""); |
| 135 | + const robotsMeta = noindex |
| 136 | + ? '<meta name="robots" content="noindex, nofollow" />' |
| 137 | + : '<meta name="robots" content="index, follow" />'; |
| 138 | + html = html.replace( |
| 139 | + "</head>", |
| 140 | + ` ${robotsMeta}\n <script id="site-structured-data" type="application/ld+json">${jsonForHtml(structuredData)}</script>\n </head>`, |
| 141 | + ); |
| 142 | + html = replaceRequired( |
| 143 | + html, |
| 144 | + /<div\s+id="root"\s*><\/div>/i, |
| 145 | + `<div id="root">${appHtml}</div>`, |
| 146 | + "React root", |
| 147 | + ); |
| 148 | + |
| 149 | + return html; |
| 150 | +}; |
0 commit comments