-
-
Save andrelandgraf/0112631dcdf6640e4bd44360d3e7a08e to your computer and use it in GitHub Desktop.
import childProcess from 'child_process'; | |
import fs from 'fs'; | |
import dotenv from 'dotenv'; | |
import prettier from 'prettier'; | |
const rootDir = process.cwd(); | |
dotenv.config({ | |
path: `${rootDir}/.env.production`, | |
}); | |
interface Route { | |
id: string; | |
path?: string; | |
file: string; | |
children?: Route[]; | |
} | |
const today = new Date().toISOString(); | |
const domain = process.env.HOST; | |
console.log(`Updating sitemap on ${today} for domain ${domain}...`); | |
const consideredRoutes: string[] = []; | |
function addPathParts(path1 = '', path2 = ''): string { | |
return path1.endsWith('/') || path2.startsWith('/') ? `${path1}${path2}` : `${path1}/${path2}`; | |
} | |
function pathToEntry(path: string): string { | |
return ` | |
<url> | |
<loc>${addPathParts(domain, path)}</loc> | |
<lastmod>${today}</lastmod> | |
<changefreq>daily</changefreq> | |
<priority>0.7</priority> | |
</url> | |
`; | |
} | |
async function depthFirstHelper(route: Route, currentPath = ''): Promise<string> { | |
let sitemapContent = ''; | |
const isLayoutRoute = !route.path; | |
const pathIncludesParam = (route.path && route.path.includes(':')) || currentPath.includes(':'); | |
if (!isLayoutRoute && !pathIncludesParam) { | |
const filePath = `${rootDir}/app/${route.file}`; | |
const routeContent = fs.readFileSync(filePath, 'utf8'); | |
// no default export means API route | |
if (routeContent.includes('export default')) { | |
const nextPath = addPathParts(currentPath, route.path); | |
const isConsidered = consideredRoutes.includes(nextPath); | |
if (!isConsidered) { | |
sitemapContent += pathToEntry(nextPath); | |
consideredRoutes.push(nextPath); | |
} | |
} | |
} | |
if (route.children) { | |
for (const childRoute of route.children) { | |
const nextPath = addPathParts(currentPath, route.path); | |
sitemapContent += await depthFirstHelper(childRoute, nextPath); | |
} | |
} | |
return sitemapContent; | |
} | |
async function routesToSitemap(routes: Route[]): Promise<string> { | |
let sitemapContent = ''; | |
for (const route of routes) { | |
sitemapContent += await depthFirstHelper(route, ''); | |
} | |
return ` | |
<?xml version="1.0" encoding="UTF-8"?> | |
<urlset | |
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" | |
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd" | |
> | |
${sitemapContent} | |
</urlset> | |
`; | |
} | |
const formatSitemap = (sitemap: string) => prettier.format(sitemap, { parser: 'html' }); | |
async function main() { | |
const output = childProcess.execSync('npx remix routes --json'); | |
const routes: Route[] = JSON.parse(output.toString()); | |
const root = routes.find((r) => r.id === 'root'); | |
if (!root) { | |
throw new Error('Root not found'); | |
} | |
const childRoutes = root.children; | |
if (!childRoutes) { | |
throw new Error('Root has no children routes'); | |
} | |
console.log(`Found ${childRoutes.length} root children routes!`); | |
const sitemap = await routesToSitemap(childRoutes); | |
const formattedSitemap = formatSitemap(sitemap); | |
fs.writeFileSync('./public/sitemap.xml', formattedSitemap, 'utf8'); | |
console.log('sitemap.xml updated 🎉'); | |
return formattedSitemap; | |
} | |
main(); |
Hey, glad this is helpful to you! Right now, the script only considers "static" routes (without route params) that don't change at runtime. That's why I use it during build time.
I like that your idea gets rid of the node code and makes it platform agnostic! I think your solution depends on the cli package though, right? You would need to install it as a dependency instead of a devDependency to use it during runtime. It would be awesome if Remix could provide an official API for that. Kent opened a ticket to access the route information at runtime through an official API: remix-run/remix#741 Maybe leave your suggestions there as well!
Alternative could be using a resource route like I explain in RSS in Remix
@CanRau yes, you will need to do that when you have dynamic content as well. Kent is doing this on his page: https://github.com/kentcdodds/kentcdodds.com/blob/main/app/other-routes.server.ts
Uh yea, also stumbled upon his approach though not (yet) sure what the reason is, on first look seems more complicated 😳
Instead of opening a child process, could you instead
require('@remix-run/dev/config/format').formatRoutesAsJson(require('./server/build/routes'))
? I don't think it's really part of the public API since you have to get it from config/format, but you would get the same result without having to shell out.This would be great to have available as a module at runtime, so in my /app/routes/sitemap[.xml].tsx I can do
Which could return a response with XML content type headers.
Thanks for making this. Great example!