Skip to content

Instantly share code, notes, and snippets.

@jacobparis
Last active April 28, 2023 18:42
Show Gist options
  • Save jacobparis/69aa352e38317d3b986a089565f5c1b6 to your computer and use it in GitHub Desktop.
Save jacobparis/69aa352e38317d3b986a089565f5c1b6 to your computer and use it in GitHub Desktop.

remix-route-extensions

This package allows you to specify .route.tsx files anywhere in your /app directory and they will be recognized as routes

Filenames follow the v2 flat routes convention

root.tsx
app
  _layout.route.tsx
  _layout._index.route.tsx
  _layout.about.route.tsx
  auth
    auth.login.route.tsx # /auth/login
    auth.login_.otp.tsx # /auth/login/otp
    auth.create-account.route.tsx # /auth/create-account
    auth.reset-password.route.tsx # /auth/reset-password
    session.server.ts
    sendgrid.server.ts
  blog
    _layout.blog._index.route.tsx # /blog
    _layout.blog._post.route.tsx # layout for blog posts
    _layout.blog._post.$slug.route.tsx # /blog/hello-world  
    blog.$slug.refresh.ts # resource route for /blog/hello-world/refresh
    blog.$slug.og[.png].ts #
    blogItem.tsx
    blogList.tsx
    cache.server.ts
    content
      hello-world.md
      goodnight-moon.md
  _layout.contact.route.tsx
  todo-app
    _layout.blog._post.todo-app.route.md # /blog/todo-app
    blog.todo-app.example.route.tsx # /blog/todo-app/example without the website layout
    db.server.ts
    seed.json # not a route, just an asset

To install, copy the above file and then add it to your remix.config

const path = require("path")
const { routeExtensions } = require("./remix-route-extensions")
/**
 * @type {import('@remix-run/dev').AppConfig}
 */
module.exports = {
  async routes() {
    return routeExtensions(path.join(__dirname, "app"), [])
  },
}
import fs from "node:fs"
import fse from "fs-extra"
import path from "node:path"
import globToRegex from "glob-to-regexp"
function findConfig(
dir: string,
basename: string,
extensions: string[],
): string | undefined {
for (let ext of extensions) {
let name = basename + ext
let file = path.join(dir, name)
if (fse.existsSync(file)) return file
}
return undefined
}
let paramPrefixChar = "$" as const
let escapeStart = "[" as const
let escapeEnd = "]" as const
let optionalStart = "(" as const
let optionalEnd = ")" as const
const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"]
function isSegmentSeparator(checkChar: string | undefined) {
if (!checkChar) return false
return ["/", ".", path.win32.sep].includes(checkChar)
}
interface ConfigRoute {
path?: string
index?: boolean
caseSensitive?: boolean
id: string
parentId?: string
file: string
}
interface RouteManifest {
[routeId: string]: ConfigRoute
}
function normalizeSlashes(file: string) {
return file.split(path.win32.sep).join("/")
}
const PrefixLookupTrieEndSymbol = Symbol("PrefixLookupTrieEndSymbol")
type PrefixLookupNode = {
[key: string]: PrefixLookupNode
} & Record<typeof PrefixLookupTrieEndSymbol, boolean>
class PrefixLookupTrie {
root: PrefixLookupNode = {
[PrefixLookupTrieEndSymbol]: false,
}
add(value: string) {
if (!value) throw new Error("Cannot add empty string to PrefixLookupTrie")
let node = this.root
for (let char of value) {
if (!node[char]) {
node[char] = {
[PrefixLookupTrieEndSymbol]: false,
}
}
node = node[char]
}
node[PrefixLookupTrieEndSymbol] = true
}
findAndRemove(
prefix: string,
filter: (nodeValue: string) => boolean,
): string[] {
let node = this.root
for (let char of prefix) {
if (!node[char]) return []
node = node[char]
}
return this.#findAndRemoveRecursive([], node, prefix, filter)
}
#findAndRemoveRecursive(
values: string[],
node: PrefixLookupNode,
prefix: string,
filter: (nodeValue: string) => boolean,
): string[] {
for (let char of Object.keys(node)) {
this.#findAndRemoveRecursive(values, node[char], prefix + char, filter)
}
if (node[PrefixLookupTrieEndSymbol] && filter(prefix)) {
node[PrefixLookupTrieEndSymbol] = false
values.push(prefix)
}
return values
}
}
/**
* @param appDirectory The base directory of your app
* @param ignoredFilePatterns An array of glob patterns to ignore
*/
export function routeExtensions(
appDirectory: string,
ignoredFilePatterns: string[] = [],
) {
let ignoredFileRegex = ignoredFilePatterns.map((pattern) => {
return globToRegex(pattern)
})
let rootRoute = findConfig(appDirectory, "root", routeModuleExts)
if (!rootRoute) {
throw new Error(
`Could not find a root route module in the app directory: ${appDirectory}`,
)
}
// Only read the routes directory
let entries = fs.readdirSync(appDirectory, {
withFileTypes: true,
encoding: "utf-8",
})
let routes: string[] = []
for (let entry of entries) {
// If it's a directory, recurse into it
if (entry.isDirectory()) {
const routesInFolders = findRouteModulesForFolder(
appDirectory,
entry.name,
ignoredFileRegex,
)
if (routesInFolders.length) {
routes.push(...routesInFolders)
}
} else if (entry.isFile()) {
const route = findRouteModuleForFile(entry.name, ignoredFileRegex)
if (route) {
routes.push(route)
}
}
}
console.log({ routes })
let routeManifest = routeExtensionsImpl(appDirectory, routes)
return routeManifest
}
export function routeExtensionsImpl(
appDirectory: string,
routes: string[],
): RouteManifest {
let urlConflicts = new Map<string, ConfigRoute[]>()
let routeManifest: RouteManifest = {}
let prefixLookup = new PrefixLookupTrie()
let uniqueRoutes = new Map<string, ConfigRoute>()
let routeIdConflicts = new Map<string, string[]>()
// id -> file
let routeIds = new Map<string, string>()
for (let file of routes) {
let normalizedFile = normalizeSlashes(file)
let routeExt = path.extname(normalizedFile)
let normalizedApp = normalizeSlashes(appDirectory)
let basename = path.basename(file)
let routeId = basename.slice(0, 0 - routeExt.length - ".route".length)
let conflict = routeIds.get(routeId)
if (conflict) {
let currentConflicts = routeIdConflicts.get(routeId)
if (!currentConflicts) {
currentConflicts = [path.posix.relative(normalizedApp, conflict)]
}
currentConflicts.push(path.posix.relative(normalizedApp, normalizedFile))
routeIdConflicts.set(routeId, currentConflicts)
continue
}
routeIds.set(routeId, normalizedFile)
}
let sortedRouteIds = Array.from(routeIds).sort(
([a], [b]) => b.length - a.length,
)
for (let [routeId, file] of sortedRouteIds) {
let routeIdNoFeature = routeId.slice(0)
let noRouteEnding = routeIdNoFeature.replace(".route", "")
let isIndex = noRouteEnding.endsWith("_index")
let [segments, raw] = getRouteSegments(noRouteEnding)
let pathname = createRoutePath(segments, raw, isIndex)
routeManifest[routeId] = {
file,
id: routeId,
path: pathname,
}
if (isIndex) routeManifest[routeId].index = true
let childRouteIds = prefixLookup.findAndRemove(routeId, (value) => {
return [".", "/"].includes(value.slice(routeId.length).charAt(0))
})
prefixLookup.add(routeId)
if (childRouteIds.length > 0) {
for (let childRouteId of childRouteIds) {
routeManifest[childRouteId].parentId = routeId
}
}
}
// path creation
let parentChildrenMap = new Map<string, ConfigRoute[]>()
for (let [routeId] of sortedRouteIds) {
let config = routeManifest[routeId]
if (!config.parentId) continue
let existingChildren = parentChildrenMap.get(config.parentId) || []
existingChildren.push(config)
parentChildrenMap.set(config.parentId, existingChildren)
}
for (let [routeId] of sortedRouteIds) {
let config = routeManifest[routeId]
let originalPathname = config.path || ""
let pathname = config.path
let parentConfig = config.parentId ? routeManifest[config.parentId] : null
if (parentConfig?.path && pathname) {
pathname = pathname
.slice(parentConfig.path.length)
.replace(/^\//, "")
.replace(/\/$/, "")
}
let conflictRouteId = originalPathname + (config.index ? "?index" : "")
let conflict = uniqueRoutes.get(conflictRouteId)
if (!config.parentId) config.parentId = "root"
config.path = pathname || undefined
uniqueRoutes.set(conflictRouteId, config)
if (conflict && (originalPathname || config.index)) {
let currentConflicts = urlConflicts.get(originalPathname)
if (!currentConflicts) currentConflicts = [conflict]
currentConflicts.push(config)
urlConflicts.set(originalPathname, currentConflicts)
continue
}
}
if (routeIdConflicts.size > 0) {
for (let [routeId, files] of routeIdConflicts.entries()) {
console.error(getRouteIdConflictErrorMessage(routeId, files))
}
}
// report conflicts
if (urlConflicts.size > 0) {
for (let [path, routes] of urlConflicts.entries()) {
// delete all but the first route from the manifest
for (let i = 1; i < routes.length; i++) {
delete routeManifest[routes[i].id]
}
let files = routes.map((r) => r.file)
console.error(getRoutePathConflictErrorMessage(path, files))
}
}
return routeManifest
}
function findRouteModuleForFile(
filepath: string,
ignoredFileRegex: RegExp[],
): string | null {
let ext = path.extname(filepath)
let basename = path.basename(filepath, ext)
if (!basename.endsWith(".route")) return null
let isIgnored = ignoredFileRegex.some((regex) => regex.test(filepath))
if (isIgnored) return null
return filepath
}
function findRouteModulesForFolder(
appDirectory: string,
filepath: string,
ignoredFileRegex: RegExp[],
): string[] {
let dirEntries = fs.readdirSync(path.join(appDirectory, filepath), {
withFileTypes: true,
encoding: "utf-8",
})
let filesOrDirs = dirEntries.filter((e) => {
if (e.isDirectory()) return true
let ext = path.extname(e.name)
let base = path.basename(e.name, ext)
return base.endsWith(".route")
})
let routes: string[] = []
for (let fileOrDir of filesOrDirs) {
if (!fileOrDir) continue
let isIgnored = ignoredFileRegex.some((regex) => regex.test(fileOrDir.name))
if (isIgnored) continue
if (fileOrDir.isDirectory()) {
routes.push(
...findRouteModulesForFolder(
appDirectory,
path.join(filepath, fileOrDir.name),
ignoredFileRegex,
),
)
} else {
routes.push(path.join(filepath, fileOrDir.name))
}
}
return routes
}
type State =
| // normal path segment normal character concatenation until we hit a special character or the end of the segment (i.e. `/`, `.`, '\')
"NORMAL"
// we hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks
| "ESCAPE"
// we hit a `(` and are now in an optional segment until we hit a `)` or an escape sequence
| "OPTIONAL"
// we previously were in a opt fional segment and hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks - afterwards go back to OPTIONAL state
| "OPTIONAL_ESCAPE"
export function getRouteSegments(routeId: string): [string[], string[]] {
let routeSegments: string[] = []
let rawRouteSegments: string[] = []
let index = 0
let routeSegment = ""
let rawRouteSegment = ""
let state: State = "NORMAL"
let pushRouteSegment = (segment: string, rawSegment: string) => {
if (!segment) return
let notSupportedInRR = (segment: string, char: string) => {
throw new Error(
`Route segment "${segment}" for "${routeId}" cannot contain "${char}".\n` +
`If this is something you need, upvote this proposal for React Router https://github.com/remix-run/react-router/discussions/9822.`,
)
}
if (rawSegment.includes("*")) {
return notSupportedInRR(rawSegment, "*")
}
if (rawSegment.includes(":")) {
return notSupportedInRR(rawSegment, ":")
}
if (rawSegment.includes("/")) {
return notSupportedInRR(segment, "/")
}
routeSegments.push(segment)
rawRouteSegments.push(rawSegment)
}
while (index < routeId.length) {
let char = routeId[index]
index++ //advance to next char
switch (state) {
case "NORMAL": {
if (isSegmentSeparator(char)) {
pushRouteSegment(routeSegment, rawRouteSegment)
routeSegment = ""
rawRouteSegment = ""
state = "NORMAL"
break
}
if (char === escapeStart) {
state = "ESCAPE"
rawRouteSegment += char
break
}
if (char === optionalStart) {
state = "OPTIONAL"
rawRouteSegment += char
break
}
if (!routeSegment && char == paramPrefixChar) {
if (index === routeId.length) {
routeSegment += "*"
rawRouteSegment += char
} else {
routeSegment += ":"
rawRouteSegment += char
}
break
}
routeSegment += char
rawRouteSegment += char
break
}
case "ESCAPE": {
if (char === escapeEnd) {
state = "NORMAL"
rawRouteSegment += char
break
}
routeSegment += char
rawRouteSegment += char
break
}
case "OPTIONAL": {
if (char === optionalEnd) {
routeSegment += "?"
rawRouteSegment += char
state = "NORMAL"
break
}
if (char === escapeStart) {
state = "OPTIONAL_ESCAPE"
rawRouteSegment += char
break
}
if (!routeSegment && char === paramPrefixChar) {
if (index === routeId.length) {
routeSegment += "*"
rawRouteSegment += char
} else {
routeSegment += ":"
rawRouteSegment += char
}
break
}
routeSegment += char
rawRouteSegment += char
break
}
case "OPTIONAL_ESCAPE": {
if (char === escapeEnd) {
state = "OPTIONAL"
rawRouteSegment += char
break
}
routeSegment += char
rawRouteSegment += char
break
}
}
}
// process remaining segment
pushRouteSegment(routeSegment, rawRouteSegment)
return [routeSegments, rawRouteSegments]
}
export function createRoutePath(
routeSegments: string[],
rawRouteSegments: string[],
isIndex?: boolean,
) {
let result: string[] = []
if (isIndex) {
routeSegments = routeSegments.slice(0, -1)
}
for (let index = 0; index < routeSegments.length; index++) {
let segment = routeSegments[index]
let rawSegment = rawRouteSegments[index]
// skip pathless layout segments
if (segment.startsWith("_") && rawSegment.startsWith("_")) {
continue
}
// remove trailing slash
if (segment.endsWith("_") && rawSegment.endsWith("_")) {
segment = segment.slice(0, -1)
}
result.push(segment)
}
return result.length ? result.join("/") : undefined
}
export function getRoutePathConflictErrorMessage(
pathname: string,
routes: string[],
) {
let [taken, ...others] = routes
if (!pathname.startsWith("/")) {
pathname = "/" + pathname
}
return (
`⚠️ Route Path Collision: "${pathname}"\n\n` +
`The following routes all define the same URL, only the first one will be used\n\n` +
`🟢 ${taken}\n` +
others.map((route) => `⭕️️ ${route}`).join("\n") +
"\n"
)
}
export function getRouteIdConflictErrorMessage(
routeId: string,
files: string[],
) {
let [taken, ...others] = files
return (
`⚠️ Route ID Collision: "${routeId}"\n\n` +
`The following routes all define the same Route ID, only the first one will be used\n\n` +
`🟢 ${taken}\n` +
others.map((route) => `⭕️️ ${route}`).join("\n") +
"\n"
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment