Skip to content

Instantly share code, notes, and snippets.

@mikkpokk
Last active January 20, 2025 11:37
Show Gist options
  • Save mikkpokk/3e9aa2e67bf6efb1cd6b1cd76393e1f8 to your computer and use it in GitHub Desktop.
Save mikkpokk/3e9aa2e67bf6efb1cd6b1cd76393e1f8 to your computer and use it in GitHub Desktop.
remix-flat-routes 0.6.5 without requiring dependency @remix-run/dev. Make sure package minimatch is included in your package.json and installed - "minimatch": "^10.0.1"
import * as fs from 'fs'
import {minimatch} from 'minimatch'
import * as path from 'path'
/**
* A route that was created using `defineRoutes` or created conventionally from
* looking at the files on the filesystem.
*/
interface ConfigRoute {
/**
* The path this route uses to match on the URL pathname.
*/
path?: string
/**
* Should be `true` if it is an index route. This disallows child routes.
*/
index?: boolean
/**
* Should be `true` if the `path` is case-sensitive. Defaults to `false`.
*/
caseSensitive?: boolean
/**
* The unique id for this route, named like its `file` but without the
* extension. So `app/routes/gists/$username.jsx` will have an `id` of
* `routes/gists/$username`.
*/
id: string
/**
* The unique `id` for this route's parent route, if there is one.
*/
parentId?: string
/**
* The path to the entry point for this route, relative to
* `config.appDirectory`.
*/
file: string
}
interface RouteManifest {
[routeId: string]: ConfigRoute
}
type RouteInfo = {
id: string
path: string
file: string
name: string
segments: string[]
parentId?: string // first pass parent is undefined
index?: boolean
caseSensitive?: boolean
}
type DefineRouteOptions = {
caseSensitive?: boolean
index?: boolean
id?: string
}
type DefineRouteChildren = {
(): void
}
interface DefineRouteFunction {
(
/**
* The path this route uses to match the URL pathname.
*/
path: string | undefined,
/**
* The path to the file that exports the React component rendered by this
* route as its default export, relative to the `app` directory.
*/
file: string,
/**
* Options for defining routes, or a function for defining child routes.
*/
optionsOrChildren?: DefineRouteOptions | DefineRouteChildren,
/**
* A function for defining child routes.
*/
children?: DefineRouteChildren,
): void
}
export type VisitFilesFunction = (
dir: string,
visitor: (file: string) => void,
baseDir?: string,
) => void
export type FlatRoutesOptions = {
appDir?: string
routeDir?: string | string[]
defineRoutes?: DefineRoutesFunction
basePath?: string
visitFiles?: VisitFilesFunction
paramPrefixChar?: string
ignoredRouteFiles?: string[]
routeRegex?: RegExp
}
export type DefineRoutesFunction = (
callback: (route: DefineRouteFunction) => void,
) => any
export type {
DefineRouteFunction,
DefineRouteOptions,
DefineRouteChildren,
RouteManifest,
RouteInfo,
}
export { flatRoutes }
const defaultOptions: FlatRoutesOptions = {
appDir: 'app',
routeDir: 'routes',
basePath: '/',
paramPrefixChar: '$',
routeRegex:
/(([+][\/\\][^\/\\:?*]+)|[\/\\]((index|route|layout|page)|(_[^\/\\:?*]+)|([^\/\\:?*]+\.route)))\.(ts|tsx|js|jsx|md|mdx)$$/,
}
const defaultDefineRoutes = undefined
export default function flatRoutes(
routeDir: string | string[],
defineRoutes: DefineRoutesFunction,
options: FlatRoutesOptions = {},
): RouteManifest {
const routes = _flatRoutes(
options.appDir ?? defaultOptions.appDir!,
options.ignoredRouteFiles ?? [],
{
...defaultOptions,
...options,
routeDir,
defineRoutes,
},
)
// update undefined parentIds to 'root'
Object.values(routes).forEach(route => {
if (route.parentId === undefined) {
route.parentId = 'root'
}
})
return routes
}
// this function uses the same signature as the one used in core remix
// this way we can continue to enhance this package and still maintain
// compatibility with remix
function _flatRoutes(
appDir: string,
ignoredFilePatternsOrOptions?: string[] | FlatRoutesOptions,
options?: FlatRoutesOptions,
): RouteManifest {
// get options
let ignoredFilePatterns: string[] = []
if (
ignoredFilePatternsOrOptions &&
!Array.isArray(ignoredFilePatternsOrOptions)
) {
options = ignoredFilePatternsOrOptions
} else {
// @ts-ignore - only array passed here
ignoredFilePatterns = ignoredFilePatternsOrOptions ?? []
}
if (!options) {
options = defaultOptions
}
let routeMap: Map<string, RouteInfo> = new Map()
let nameMap: Map<string, RouteInfo> = new Map()
let routeDirs = Array.isArray(options.routeDir)
? options.routeDir
: [options.routeDir ?? 'routes']
let defineRoutes = options.defineRoutes ?? defaultDefineRoutes
if (!defineRoutes) {
throw new Error('You must provide a defineRoutes function')
}
let visitFiles = options.visitFiles ?? defaultVisitFiles
let routeRegex = options.routeRegex ?? defaultOptions.routeRegex!
for (let routeDir of routeDirs) {
visitFiles(path.join(appDir, routeDir), file => {
if (
ignoredFilePatterns &&
ignoredFilePatterns.some(pattern =>
minimatch(file, pattern, { dot: true }),
)
) {
return
}
if (isRouteModuleFile(file, routeRegex)) {
let routeInfo = getRouteInfo(routeDir, file, options!)
routeMap.set(routeInfo.id, routeInfo)
nameMap.set(routeInfo.name, routeInfo)
return
}
})
}
// update parentIds for all routes
Array.from(routeMap.values()).forEach(routeInfo => {
let parentId = findParentRouteId(routeInfo, nameMap)
routeInfo.parentId = parentId
})
// Then, recurse through all routes using the public defineRoutes() API
function defineNestedRoutes(
defineRoute: DefineRouteFunction,
parentId?: string,
): void {
let childRoutes = Array.from(routeMap.values()).filter(
routeInfo => routeInfo.parentId === parentId,
)
let parentRoute = parentId ? routeMap.get(parentId) : undefined
let parentRoutePath = parentRoute?.path ?? '/'
for (let childRoute of childRoutes) {
let routePath = childRoute?.path?.slice(parentRoutePath.length) ?? ''
// remove leading slash
if (routePath.startsWith('/')) {
routePath = routePath.slice(1)
}
let index = childRoute.index
if (index) {
let invalidChildRoutes = Object.values(routeMap).filter(
routeInfo => routeInfo.parentId === childRoute.id,
)
if (invalidChildRoutes.length > 0) {
throw new Error(
`Child routes are not allowed in index routes. Please remove child routes of ${childRoute.id}`,
)
}
defineRoute(routePath, routeMap.get(childRoute.id!)!.file, {
index: true,
})
} else {
defineRoute(routePath, routeMap.get(childRoute.id!)!.file, () => {
defineNestedRoutes(defineRoute, childRoute.id)
})
}
}
}
let routes = defineRoutes(defineNestedRoutes)
return routes
}
const routeModuleExts = ['.js', '.jsx', '.ts', '.tsx', '.md', '.mdx']
const serverRegex = /\.server\.(ts|tsx|js|jsx|md|mdx)$/
const indexRouteRegex =
/((^|[.]|[+]\/)(index|_index))(\/[^\/]+)?$|(\/_?index\/)/
export function isRouteModuleFile(
filename: string,
routeRegex: RegExp,
): boolean {
// flat files only need correct extension
let isFlatFile = !filename.includes(path.sep)
if (isFlatFile) {
return routeModuleExts.includes(path.extname(filename))
}
let isRoute = routeRegex.test(filename)
if (isRoute) {
// check to see if it ends in .server.tsx because you may have
// a _route.tsx and and _route.server.tsx and only the _route.tsx
// file should be considered a route
let isServer = serverRegex.test(filename)
return !isServer
}
return false
}
export function isIndexRoute(routeId: string): boolean {
return indexRouteRegex.test(routeId)
}
export function getRouteInfo(
routeDir: string,
file: string,
options: FlatRoutesOptions,
) {
let filePath = normalizeSlashes(path.join(routeDir, file))
let routeId = createRouteId(filePath)
let routeIdWithoutRoutes = routeId.slice(routeDir.length + 1)
let index = isIndexRoute(routeIdWithoutRoutes)
let routeSegments = getRouteSegments(
routeIdWithoutRoutes,
index,
options.paramPrefixChar,
)
let routePath = createRoutePath(routeSegments, index, options)
let routeInfo = {
id: routeId,
path: routePath!,
file: filePath,
name: routeSegments.join('/'),
segments: routeSegments,
index,
}
return routeInfo
}
// create full path starting with /
export function createRoutePath(
routeSegments: string[],
index: boolean,
options: FlatRoutesOptions,
): string | undefined {
let result = ''
let basePath = options.basePath ?? '/'
let paramPrefixChar = options.paramPrefixChar ?? '$'
if (index) {
// replace index with blank
routeSegments[routeSegments.length - 1] = ''
}
for (let i = 0; i < routeSegments.length; i++) {
let segment = routeSegments[i]
// skip pathless layout segments
if (segment.startsWith('_')) {
continue
}
// remove trailing slash
if (segment.endsWith('_')) {
segment = segment.slice(0, -1)
}
// handle param segments: $ => *, $id => :id
if (segment.startsWith(paramPrefixChar)) {
if (segment === paramPrefixChar) {
result += `/*`
} else {
result += `/:${segment.slice(1)}`
}
// handle optional segments with param: ($segment) => :segment?
} else if (segment.startsWith(`(${paramPrefixChar}`)) {
result += `/:${segment.slice(2, segment.length - 1)}?`
// handle optional segments: (segment) => segment?
} else if (segment.startsWith('(')) {
result += `/${segment.slice(1, segment.length - 1)}?`
} else {
result += `/${segment}`
}
}
if (basePath !== '/') {
result = basePath + result
}
return result || undefined
}
function findParentRouteId(
routeInfo: RouteInfo,
nameMap: Map<string, RouteInfo>,
): string | undefined {
let parentName = routeInfo.segments.slice(0, -1).join('/')
while (parentName) {
if (nameMap.has(parentName)) {
return nameMap.get(parentName)!.id
}
parentName = parentName.substring(0, parentName.lastIndexOf('/'))
}
return undefined
}
export function getRouteSegments(
name: string,
index: boolean,
paramPrefixChar: string = '$',
) {
let routeSegments: string[] = []
let i = 0
let routeSegment = ''
let state = 'START'
let subState = 'NORMAL'
let hasPlus = false
// name has already been normalized to use / as path separator
// replace `+/_.` with `_+/`
// this supports ability to to specify parent folder will not be a layout
// _public+/_.about.tsx => _public_.about.tsx
if (/\+\/_\./.test(name)) {
name = name.replace(/\+\/_\./g, '_+/')
}
// replace `+/` with `.`
// this supports folders for organizing flat-files convention
// _public+/about.tsx => _public.about.tsx
//
if (/\+\//.test(name)) {
name = name.replace(/\+\//g, '.')
hasPlus = true
}
let hasFolder = /\//.test(name)
// if name has plus folder, but we still have regular folders
// then treat ending route as flat-folders
if (((hasPlus && hasFolder) || !hasPlus) && !name.endsWith('.route')) {
// do not remove segments ending in .route
// since these would be part of the route directory name
// docs/readme.route.tsx => docs/readme
// remove last segment since this should just be the
// route filename and we only want the directory name
// docs/_layout.tsx => docs
let last = name.lastIndexOf('/')
if (last >= 0) {
name = name.substring(0, last)
}
}
let pushRouteSegment = (routeSegment: string) => {
if (routeSegment) {
routeSegments.push(routeSegment)
}
}
while (i < name.length) {
let char = name[i]
switch (state) {
case 'START':
// process existing segment
if (
routeSegment.includes(paramPrefixChar) &&
!(
routeSegment.startsWith(paramPrefixChar) ||
routeSegment.startsWith(`(${paramPrefixChar}`)
)
) {
throw new Error(
`Route params must start with prefix char ${paramPrefixChar}: ${routeSegment}`,
)
}
if (
routeSegment.includes('(') &&
!routeSegment.startsWith('(') &&
!routeSegment.endsWith(')')
) {
throw new Error(
`Optional routes must start and end with parentheses: ${routeSegment}`,
)
}
pushRouteSegment(routeSegment)
routeSegment = ''
state = 'PATH'
continue // restart without advancing index
case 'PATH':
if (isPathSeparator(char) && subState === 'NORMAL') {
state = 'START'
break
} else if (char === '[') {
subState = 'ESCAPE'
break
} else if (char === ']') {
subState = 'NORMAL'
break
}
routeSegment += char
break
}
i++ // advance to next character
}
// process remaining segment
pushRouteSegment(routeSegment)
// strip trailing .route segment
if (routeSegments.at(-1) === 'route') {
routeSegments = routeSegments.slice(0, -1)
}
// if hasPlus, we need to strip the trailing segment if it starts with _
// and route is not an index route
// this is to handle layouts in flat-files
// _public+/_layout.tsx => _public.tsx
// _public+/index.tsx => _public.index.tsx
if (!index && hasPlus && routeSegments.at(-1)?.startsWith('_')) {
routeSegments = routeSegments.slice(0, -1)
}
return routeSegments
}
const pathSeparatorRegex = /[\/\\.]/
function isPathSeparator(char: string) {
return pathSeparatorRegex.test(char)
}
export function defaultVisitFiles(
dir: string,
visitor: (file: string) => void,
baseDir = dir,
) {
for (let filename of fs.readdirSync(dir)) {
let file = path.resolve(dir, filename)
let stat = fs.statSync(file)
if (stat.isDirectory()) {
defaultVisitFiles(file, visitor, baseDir)
} else if (stat.isFile()) {
visitor(path.relative(baseDir, file))
}
}
}
export function createRouteId(file: string) {
return normalizeSlashes(stripFileExtension(file))
}
export function normalizeSlashes(file: string) {
return file.split(path.win32.sep).join('/')
}
function stripFileExtension(file: string) {
return file.replace(/\.[a-z0-9]+$/i, '')
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment