tsx ./remix-ssg-cli --help
Same options can be provided as SSG_* envvars.
| import path from 'node:path'; | |
| import { createRequestHandler } from '@remix-run/server-runtime'; | |
| import fse from 'fs-extra'; | |
| import { parse } from 'node-html-parser'; | |
| import invariant from 'tiny-invariant'; | |
| import type { ServerBuild } from '@remix-run/node'; | |
| import nconf from 'nconf'; | |
| import { z } from 'zod'; | |
| import chalk from 'chalk'; | |
| function ArgParser() { | |
| const schema = z | |
| .object({ | |
| build: z | |
| .string() | |
| .default('build/server') | |
| .transform((value) => { | |
| const resolved = path.resolve(value); | |
| // is the resolve path a directory? | |
| // if so, we should add index.js | |
| if ( | |
| fse.existsSync(resolved) && | |
| fse.statSync(resolved).isDirectory() | |
| ) { | |
| return path.join(resolved, 'index.js'); | |
| } | |
| return resolved; | |
| }), | |
| output: z | |
| .string() | |
| .default('build/client') | |
| .transform((value) => { | |
| return path.resolve(value); | |
| }), | |
| basename: z.string().optional(), | |
| discover: z | |
| .union([ | |
| z.literal('true').transform(() => true), | |
| z.literal('false').transform(() => false), | |
| z.null().transform(() => true), | |
| ]) | |
| .default('false'), | |
| }) | |
| .strict(); | |
| const config = nconf.argv().env({ | |
| lowerCase: true, | |
| whitelist: Object.keys(schema.shape).map( | |
| (key) => `ssg_${key.toLowerCase()}`, | |
| ), | |
| }); | |
| const { _, $0, ...values } = config.get(); | |
| return schema.parse(values); | |
| } | |
| function Info(prefix: string, message: string) { | |
| return chalk`{blue ℹ️ ${prefix}} {white ${message}}`; | |
| } | |
| function Warning(prefix: string, message: string) { | |
| return chalk`{yellow ⚠️ ${prefix}} {white ${message}}`; | |
| } | |
| function Error(prefix: string, message: string) { | |
| return chalk`{red ⁉️ ${prefix}} {white ${message}}`; | |
| } | |
| function Success(prefix: string, message: string) { | |
| return chalk`{green ✔️ ${prefix}} {white ${message}}`; | |
| } | |
| function Fatal(prefix: string, message: string) { | |
| return chalk`{red 🔥 ${prefix}} {white ${message}}`; | |
| } | |
| /** | |
| * A set of paths that have been seen | |
| * We don't care about the order of the paths, just that they have been seen | |
| */ | |
| type SeenPaths = Set<string>; | |
| function PreparePathName(pathname: string) { | |
| // Crawl with a trailing slash to avoid hydration issues | |
| if (!pathname.endsWith('/')) { | |
| pathname = pathname + '/'; | |
| } | |
| return pathname; | |
| } | |
| /** | |
| * Generate a file writer that writes a rendered document or data to a file | |
| */ | |
| async function CreateFileWriter({ | |
| createFileName, | |
| }: { | |
| createFileName: (pathname: string) => string; | |
| }) { | |
| return async function WriteRenderedDocumentToFile( | |
| pathname: string, | |
| content: string, | |
| ) { | |
| const outputPath = createFileName(pathname); | |
| console.group(Info('WRITE', `Writing ${pathname} to ${outputPath}`)); | |
| try { | |
| await fse.outputFile(outputPath, content); | |
| console.log(Success('WRITE', `Done.`)); | |
| console.groupEnd(); | |
| } catch (error) { | |
| console.error(Error('WRITE', 'Failed'), error); | |
| console.groupEnd(); | |
| } | |
| }; | |
| } | |
| type FileWriter = Awaited<ReturnType<typeof CreateFileWriter>>; | |
| /** | |
| * Create a file fetcher that fetches a file from the server | |
| * | |
| * the request path is formatted by the formatRequestPath function | |
| */ | |
| async function CreateFileFetcher({ | |
| requestHandler, | |
| formatRequestPath, | |
| }: { | |
| requestHandler: ReturnType<typeof createRequestHandler>; | |
| formatRequestPath: (pathname: string) => string; | |
| }) { | |
| return async function ( | |
| pathname: string, | |
| ): Promise<{ content: string; requestPath: string }> { | |
| let request: Request | null = null; | |
| let response: Response | null = null; | |
| let requestPath: string; | |
| try { | |
| requestPath = formatRequestPath(pathname); | |
| } catch (error) { | |
| console.error(`Failed to format request path for ${pathname}`); | |
| return { content: '', requestPath: '' }; | |
| } | |
| console.group(Info('FETCH', `Requesting ${requestPath} from the server`)); | |
| try { | |
| // localhost here is a placeholder, we are not actually making a network request | |
| request = new Request(`http://localhost${requestPath}`); | |
| response = await requestHandler(request); | |
| console.groupEnd(); | |
| } catch (error) { | |
| console.error( | |
| Error('FETCH', `Failed to fetch ${requestPath} from the server`), | |
| ); | |
| console.groupEnd(); | |
| return { content: '', requestPath }; | |
| } | |
| if (!response.ok) { | |
| console.error( | |
| Error( | |
| 'FETCH', | |
| `Failed to fetch ${requestPath} from the server. Status: ${response.status}`, | |
| ), | |
| ); | |
| console.groupEnd(); | |
| return { content: '', requestPath }; | |
| } | |
| const content = await response.text(); | |
| console.log( | |
| Success('FETCH', `Fetched ${requestPath} from the server successfully`), | |
| ); | |
| return { content, requestPath }; | |
| }; | |
| } | |
| type FileFetcher = Awaited<ReturnType<typeof CreateFileFetcher>>; | |
| /** | |
| * Create a link sniffer that sniffs internal links from a document. | |
| * | |
| * Used to find internal links in a document that have not been crawled yet. | |
| */ | |
| function CreateLinkSniffer({ | |
| crawled, | |
| queued, | |
| }: { | |
| crawled: SeenPaths; | |
| queued: string[]; | |
| }) { | |
| return async function GetInteralSiteLinksFromDocument( | |
| document: ReturnType<typeof parse>, | |
| ) { | |
| const output: string[] = []; | |
| if (!document) { | |
| return output; | |
| } | |
| const links = document.querySelectorAll('a'); | |
| console.log( | |
| Success('SNIFF', `Found ${links.length} link(s) in the document`), | |
| ); | |
| for (const link of links) { | |
| const href = link.getAttribute('href'); | |
| if (!href) { | |
| continue; | |
| } | |
| if (!href.startsWith('/')) { | |
| continue; | |
| } | |
| if (crawled.has(href)) { | |
| continue; | |
| } | |
| if (queued.includes(href)) { | |
| continue; | |
| } | |
| output.push(href); | |
| } | |
| return output; | |
| }; | |
| } | |
| /* | |
| * Create a link crawler that crawls a link and its internal links. | |
| This performs the main work of the static site generator. | |
| */ | |
| function CreateLinkCrawler({ | |
| queuedLinks, | |
| isSingleFetch, | |
| pageFetcher, | |
| pageWriter, | |
| dataFetcher, | |
| dataWriter, | |
| }: { | |
| queuedLinks: string[]; | |
| isSingleFetch?: boolean; | |
| pageFetcher: FileFetcher; | |
| dataFetcher: FileFetcher; | |
| pageWriter: FileWriter; | |
| dataWriter: FileWriter; | |
| }) { | |
| const crawled: SeenPaths = new Set(); | |
| const sniffer = CreateLinkSniffer({ | |
| crawled, | |
| queued: queuedLinks, | |
| }); | |
| return async function CrawlLink(pathname: string) { | |
| invariant( | |
| pathname.startsWith('/'), | |
| Fatal('CRAWL', 'Pathname must start with /'), | |
| ); | |
| // we dont need to crawl the same link twice | |
| if (crawled.has(pathname)) { | |
| return; | |
| } | |
| console.group(Info('CRAWL', `Crawling ${pathname}`)); | |
| const document = await pageFetcher(pathname); | |
| if (!document) { | |
| console.groupEnd(); | |
| crawled.add(pathname); | |
| return; | |
| } | |
| await pageWriter(pathname, document.content); | |
| if (isSingleFetch) { | |
| console.log(Info('CRAWL', 'Single fetch mode enabled, fetching data')); | |
| const documentData = await dataFetcher(pathname); | |
| await dataWriter(documentData.requestPath, documentData.content); | |
| } | |
| // Queue document internal links | |
| const internalLinks = await sniffer(parse(document.content)); | |
| if (internalLinks.length === 0) { | |
| console.groupEnd(); | |
| console.log(Warning('CRAWL', `No internal links found in ${pathname}`)); | |
| crawled.add(pathname); | |
| return; | |
| } | |
| crawled.add(pathname); | |
| console.groupEnd(); | |
| console.log( | |
| Success('CRAWL', `Found ${internalLinks.length} internal link(s)`), | |
| ); | |
| internalLinks.forEach((link) => { | |
| queuedLinks.push(link); | |
| }); | |
| }; | |
| } | |
| /** | |
| * Initialize and provide a static site generator renderer function | |
| */ | |
| async function SsgSiteRenderer( | |
| remixServer: ServerBuild, | |
| options: ReturnType<typeof ArgParser>, | |
| ) { | |
| const requestHandler = createRequestHandler(remixServer, 'production'); | |
| const isSingleFetch = !!remixServer.future.unstable_singleFetch; | |
| const pageFetcher = await CreateFileFetcher({ | |
| requestHandler, | |
| formatRequestPath: (pathname) => pathname, | |
| }); | |
| const pageWriter = await CreateFileWriter({ | |
| createFileName: (pathname) => { | |
| return path.join(options.output, pathname, 'index.html'); | |
| }, | |
| }); | |
| const dataFetcher = await CreateFileFetcher({ | |
| requestHandler, | |
| formatRequestPath: (pathname) => { | |
| return pathname === '/' | |
| ? '/_root.data' | |
| : `${pathname.replace(/\/$/, '')}.data`; | |
| }, | |
| }); | |
| const dataWriter = await CreateFileWriter({ | |
| createFileName: (pathname) => { | |
| return path.join(options.output, pathname); | |
| }, | |
| }); | |
| return async (routes: string[]) => { | |
| console.log( | |
| Info('SSG', `Processing ${routes.length} route(s) to ${options.output}`), | |
| ); | |
| const queuedLinks: string[] = Array.from(routes); | |
| const crawler = CreateLinkCrawler({ | |
| queuedLinks, | |
| pageFetcher, | |
| pageWriter, | |
| dataFetcher, | |
| dataWriter, | |
| isSingleFetch, | |
| }); | |
| while (queuedLinks.length > 0) { | |
| const href = queuedLinks.shift(); | |
| if (!href) { | |
| break; | |
| } | |
| const pathname = PreparePathName(href); | |
| console.group( | |
| Info('SSG', `Processing ${pathname} (${queuedLinks.length} left)`), | |
| ); | |
| await crawler(pathname); | |
| console.groupEnd(); | |
| } | |
| return; | |
| }; | |
| } | |
| /** | |
| * Predicates | |
| */ | |
| /** | |
| * Check if a module has a getStaticPaths function | |
| */ | |
| function isModuleWithStaticPaths( | |
| module: unknown, | |
| ): module is { getStaticPaths: () => Promise<string[]> } { | |
| if (!module) { | |
| return false; | |
| } | |
| if (typeof module !== 'object') { | |
| return false; | |
| } | |
| if (!('getStaticPaths' in module)) { | |
| return false; | |
| } | |
| return typeof module.getStaticPaths === 'function'; | |
| } | |
| /** | |
| * Check if a module has a path property | |
| */ | |
| function isModuleWithPath(module: unknown): module is { path: string } { | |
| if (!module) { | |
| return false; | |
| } | |
| if (typeof module !== 'object') { | |
| return false; | |
| } | |
| if (!('path' in module)) { | |
| return false; | |
| } | |
| return typeof module.path === 'string'; | |
| } | |
| function isModulePathParameterised(path: string): boolean { | |
| return path.includes(':'); | |
| } | |
| function buildRoutePath( | |
| route: ServerBuild['routes'][number], | |
| routes: ServerBuild['routes'], | |
| ): string { | |
| if (route.parentId) { | |
| const parent = routes[route.parentId]; | |
| return [buildRoutePath(parent, routes), route.path].join('/'); | |
| } | |
| return route.path || ''; | |
| } | |
| /** | |
| * Discover static paths from a Remix server build | |
| * | |
| * This function will crawl the server build and execute any getStaticPaths functions. | |
| * | |
| * If you discover that discovered links in generated pages are not covering all | |
| * the pages you expect, you may need to add a getStaticPaths function to your routes. | |
| */ | |
| async function AccumulateRoutes( | |
| routes: ServerBuild['routes'], | |
| options: ReturnType<typeof ArgParser>, | |
| ) { | |
| const output: string[] = []; | |
| const accumulated: Promise<string[]>[] = []; | |
| console.group( | |
| Info('DISCOVER', 'Discovering static paths from Remix server build'), | |
| ); | |
| for (const [, route] of Object.entries(routes)) { | |
| console.group( | |
| Info('DISCOVER', `Checking route ${route.path} (${route.id})`), | |
| ); | |
| const routePath = buildRoutePath(route, routes); | |
| if ( | |
| isModuleWithPath(route) && | |
| isModulePathParameterised(route.path) && | |
| !isModuleWithStaticPaths(route.module) | |
| ) { | |
| console.log( | |
| Warning( | |
| 'DISCOVER', | |
| `Skipping route ${routePath} (${route.id}) as it is parameterised. Export getStaticPaths in your route module to generate static paths.`, | |
| ), | |
| ); | |
| continue; | |
| } | |
| if (isModuleWithPath(route) && !isModulePathParameterised(route.path)) { | |
| output.push(routePath.startsWith('/') ? routePath : `/${routePath}`); | |
| console.log( | |
| Success('DISCOVER', `Discovered route ${routePath} (${route.id})`), | |
| ); | |
| } | |
| if (isModuleWithStaticPaths(route.module)) { | |
| try { | |
| const staticRoutes = route.module.getStaticPaths(); | |
| accumulated.push(staticRoutes); | |
| console.log( | |
| Success('DISCOVER', `Discovered getStaticPaths in ${routePath}`), | |
| ); | |
| } catch (error) { | |
| console.error( | |
| Error('DISCOVER', `Failed to execute getStaticPaths in ${routePath}`), | |
| error, | |
| ); | |
| } | |
| } | |
| console.groupEnd(); | |
| } | |
| await Promise.all(accumulated).then((results) => { | |
| const staticPaths = results.flat(); | |
| if (!staticPaths) { | |
| return; | |
| } | |
| if (!Array.isArray(staticPaths)) { | |
| return; | |
| } | |
| for (const path of staticPaths) { | |
| if (!path || typeof path !== 'string') { | |
| continue; | |
| } | |
| if (options.basename) { | |
| output.push(options.basename + path.replace(/^\//, '')); | |
| } else { | |
| output.push(path); | |
| } | |
| } | |
| }); | |
| if (output.length === 0) { | |
| console.log( | |
| Warning( | |
| 'DISCOVER', | |
| 'No static paths discovered. Ensure you have a getStaticPaths function in your routes.', | |
| ), | |
| ); | |
| } else { | |
| console.log( | |
| Success('DISCOVER', `Discovered ${output.length} static path(s)`), | |
| ); | |
| } | |
| return output; | |
| } | |
| async function main() { | |
| const options = ArgParser(); | |
| const remixServer = await import(options.build); | |
| const renderer = await SsgSiteRenderer(remixServer, options); | |
| const paths = await AccumulateRoutes(remixServer.routes, options); | |
| await renderer(paths); | |
| } | |
| main(); |