Skip to content

Instantly share code, notes, and snippets.

@andrelandgraf
Last active January 29, 2024 08:36
Show Gist options
  • Save andrelandgraf/0112631dcdf6640e4bd44360d3e7a08e to your computer and use it in GitHub Desktop.
Save andrelandgraf/0112631dcdf6640e4bd44360d3e7a08e to your computer and use it in GitHub Desktop.
sitemap.xml generator for remix.run
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();
@smith
Copy link

smith commented Dec 16, 2021

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

import { getSiteMapLoader } from 'remix-sitemap'
export const loader = getSiteMapLoader

Which could return a response with XML content type headers.

Thanks for making this. Great example!

@andrelandgraf
Copy link
Author

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!

@CanRau
Copy link

CanRau commented Dec 25, 2021

Alternative could be using a resource route like I explain in RSS in Remix

@andrelandgraf
Copy link
Author

@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

@CanRau
Copy link

CanRau commented Dec 28, 2021

Uh yea, also stumbled upon his approach though not (yet) sure what the reason is, on first look seems more complicated 😳

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment