Skip to content
/ unrouting Public

Making filesystem routing universal

License

Notifications You must be signed in to change notification settings

unjs/unrouting

Repository files navigation

📍 unrouting

npm version npm downloads bundle Codecov License JSDocs

Universal filesystem routing

unrouting parses file paths into a route tree and emits route definitions for any framework router. It handles nested routes, dynamic params, catchalls, optional segments, route groups, layer merging, and more – as a standalone, framework-agnostic library.

Status

In active development. The core pipeline (parse, tree, emit) is functional and used by Nuxt.

  • Generic route parsing covering major filesystem routing patterns
  • Route tree with nesting, layer merging, group transparency
  • Layer priority (multiple roots with configurable file precedence)
  • Incremental tree updates (addFile/removeFile for dev server HMR)
  • Pluggable route name generation
  • Route ordering by segment priority (static > dynamic > optional > catchall)
  • Named view support (@viewName convention)
  • Mode variant support (.client, .server, configurable)
  • Duplicate route name detection
  • Emit to framework routers

Install

# npm
npm install unrouting

# pnpm
pnpm install unrouting

Usage

The library has a three-phase pipeline: parse file paths into tokens, build a route tree, and emit to a target format. For most use cases you only need two function calls.

Quick start

import { buildTree, toVueRouter4 } from 'unrouting'

const tree = buildTree([
  'pages/index.vue',
  'pages/about.vue',
  'pages/users/[id].vue',
  'pages/users.vue',
  'pages/[...slug].vue',
], { roots: ['pages/'] })

const routes = toVueRouter4(tree)
// [
//   { name: 'index', path: '/', file: 'pages/index.vue', children: [] },
//   { name: 'about', path: '/about', file: 'pages/about.vue', children: [] },
//   { name: 'users', path: '/users', file: 'pages/users.vue', children: [
//     { name: 'users-id', path: ':id()', file: 'pages/users/[id].vue', children: [] },
//   ]},
//   { name: 'slug', path: '/:slug(.*)*', file: 'pages/[...slug].vue', children: [] },
// ]

Nuxt-like usage with layers

import { buildTree, toVueRouter4 } from 'unrouting'

// Files from app + layer directories with priority
const tree = buildTree([
  { path: 'pages/index.vue', priority: 0 }, // app layer (wins on collision)
  { path: 'pages/dashboard.vue', priority: 0 },
  { path: 'pages/dashboard/settings.vue', priority: 0 },
  { path: 'layer/pages/dashboard/analytics.vue', priority: 1 }, // extending layer
  { path: 'layer/pages/index.vue', priority: 1 }, // overridden by app layer
], {
  roots: ['pages/', 'layer/pages/'],
  extensions: ['.vue'],
  modes: ['client', 'server'],
  warn: msg => console.warn(msg),
})

const routes = toVueRouter4(tree, {
  onDuplicateRouteName: (name, file, existingFile) => {
    console.warn(`Duplicate route name "${name}": ${file} and ${existingFile}`)
  },
})

Emitting to different formats

All emitters accept a RouteTree:

import { buildTree, toRegExp, toRou3, toVueRouter4 } from 'unrouting'

const tree = buildTree(['users/[id]/posts/[slug].vue'])

// Vue Router 4 – nested routes with names, files, children
const vueRoutes = toVueRouter4(tree)
// [{ name: 'users-id-posts-slug', path: '/users/:id()/posts/:slug()', file: '...', children: [] }]

// rou3/Nitro – flat route patterns
const rou3Routes = toRou3(tree)
// [{ path: '/users/:id/posts/:slug', file: '...' }]

// RegExp – matcher patterns with named groups
const regexpRoutes = toRegExp(tree)
// [{ pattern: /^\/users\/(?<id>[^/]+)\/posts\/(?<slug>[^/]+)\/?$/, keys: ['id', 'slug'], file: '...' }]

Incremental updates (dev server)

The route tree is mutable. Instead of rebuilding everything when a file changes, use addFile and removeFile to update the tree in place – avoiding the cost of re-parsing all files and reconstructing the tree from scratch on every change.

import { addFile, buildTree, removeFile, toVueRouter4 } from 'unrouting'

const opts = { roots: ['pages/'], extensions: ['.vue'] }

// Build once at startup
const tree = buildTree(initialFiles, opts)
let routes = toVueRouter4(tree)

// On file add/remove (e.g., from a watcher callback)
addFile(tree, 'pages/new-page.vue', opts)
routes = toVueRouter4(tree)

removeFile(tree, 'pages/old-page.vue')
routes = toVueRouter4(tree)

// Rename = remove + add
removeFile(tree, 'pages/old-name.vue')
addFile(tree, 'pages/new-name.vue', opts)
routes = toVueRouter4(tree)

addFile supports the same InputFile format as buildTree for layer priority:

addFile(tree, { path: 'layer/pages/about.vue', priority: 1 }, opts)

Standalone parsing and segment conversion

If you don't need the full tree pipeline – e.g., you already have resolved routes and only need to convert individual path segments or strings to Vue Router syntax – you can use the parse + convert functions directly:

import { parsePath, parseSegment, toVueRouterPath, toVueRouterSegment } from 'unrouting'

// Parse a full file path
const [result] = parsePath(['users/[id]/profile.vue'])
// {
//   file: 'users/[id]/profile.vue',
//   segments: [
//     [{ type: 'static', value: 'users' }],
//     [{ type: 'dynamic', value: 'id' }],
//     [{ type: 'static', value: 'profile' }],
//   ],
// }

// Convert parsed segments to a Vue Router path
toVueRouterPath(result.segments) // => '/users/:id()/profile'

// Parse and convert a single segment (e.g., i18n per-locale route path)
const tokens = parseSegment('[...slug]')
// [{ type: 'catchall', value: 'slug' }]
toVueRouterSegment(tokens) // => ':slug(.*)*'

Supported patterns

Pattern Example Description
Static about.vue Static route segment
Index index.vue Index page (maps to /)
Dynamic [slug].vue Required parameter
Optional [[slug]].vue Optional parameter
Catchall [...slug].vue Catch-all (zero or more segments)
Repeatable [slug]+.vue One or more segments
Optional repeatable [[slug]]+.vue Zero or more segments
Group (admin)/dashboard.vue Route group (transparent to path, stored in meta)
Mixed prefix-[slug]-suffix.vue Static and dynamic in one segment
Nested parent.vue + parent/child.vue Parent layout with child routes
Named views index@sidebar.vue Vue Router named view slots
Modes page.client.vue Mode variants (configurable suffixes)

API

buildTree(input, options?)

Build a route tree from file paths. Accepts raw strings, InputFile[] (with priority), or pre-parsed ParsedPath[].

function buildTree(
  input: string[] | InputFile[] | ParsedPath[],
  options?: BuildTreeOptions
): RouteTree

interface InputFile {
  path: string
  /** Lower number = higher priority. Default: 0 */
  priority?: number
}

Options (extends ParsePathOptions):

Option Type Description
roots string[] Root paths to strip (e.g., ['pages/', 'layer/pages/'])
extensions string[] File extensions to strip (default: strip all)
modes string[] Mode suffixes to detect (e.g., ['client', 'server'])
warn (msg: string) => void Warning callback for invalid characters in dynamic params
duplicateStrategy 'first-wins' | 'last-wins' | 'error' How to handle duplicate paths (default: 'first-wins')

When files from different layers collide at the same tree position, the file with the lowest priority number wins regardless of insertion order.

addFile(tree, filePath, options?)

Add a single file to an existing route tree in place. Parses the file and inserts it, avoiding a full rebuild. Accepts a plain string or InputFile with priority.

function addFile(
  tree: RouteTree,
  filePath: string | InputFile,
  options?: BuildTreeOptions
): void

removeFile(tree, filePath)

Remove a file from an existing route tree by its original file path. Prunes empty structural nodes left behind. Returns true if the file was found and removed.

function removeFile(tree: RouteTree, filePath: string): boolean

toVueRouter4(tree, options?)

Emit Vue Router 4 route definitions from a tree. Handles nested routes, names, index promotion, structural collapse, groups, catchall optimisation, route ordering, named views, and mode variants.

function toVueRouter4(tree: RouteTree, options?: VueRouterEmitOptions): VueRoute[]

interface VueRoute {
  name?: string
  path: string
  file?: string
  /** Named view components. Only present when multiple views exist. */
  components?: Record<string, string>
  /** Mode variants. Only present when mode files exist. */
  modes?: string[]
  children: VueRoute[]
  meta?: Record<string, unknown>
}

interface VueRouterEmitOptions {
  /** Custom name generator. Receives raw `/`-separated name, returns final name. */
  getRouteName?: (rawName: string) => string
  /** Called when two routes produce the same name. */
  onDuplicateRouteName?: (name: string, file: string, existingFile: string) => void
}

Routes are sorted by segment priority within each level: static segments first, then dynamic, optional, and catchall last.

toRou3(tree)

Emit rou3/Nitro route patterns from a tree.

function toRou3(tree: RouteTree): Rou3Route[]

interface Rou3Route {
  path: string
  file: string
}

toRegExp(tree)

Emit RegExp matchers from a tree.

function toRegExp(tree: RouteTree): RegExpRoute[]

interface RegExpRoute {
  pattern: RegExp
  keys: string[]
  file: string
}

toVueRouterSegment(tokens, options?)

Convert a single parsed segment (an array of tokens returned by parseSegment) into a Vue Router 4 path segment string. Useful for modules that already have resolved routes and only need segment-level path conversion (e.g., @nuxtjs/i18n converting per-locale custom paths).

function toVueRouterSegment(
  tokens: ParsedPathSegmentToken[],
  options?: ToVueRouterSegmentOptions
): string

interface ToVueRouterSegmentOptions {
  /**
   * Whether non-index segments follow this one.
   * When true, catchall uses ([^/]*)*; when false (default), uses (.*)*
   */
  hasSucceeding?: boolean
}
import { parseSegment, toVueRouterSegment } from 'unrouting'

toVueRouterSegment(parseSegment('[id]')) // => ':id()'
toVueRouterSegment(parseSegment('[[opt]]')) // => ':opt?'
toVueRouterSegment(parseSegment('[...slug]')) // => ':slug(.*)*'
toVueRouterSegment(parseSegment('prefix-[slug]')) // => 'prefix-:slug()'

// i18n use case – parse a custom locale path segment
const tokens = parseSegment('[foo]_[bar]:[...buz]_buz_[[qux]]')
const path = `/${toVueRouterSegment(tokens)}`
// => '/:foo()_:bar()\::buz(.*)*_buz_:qux?'

toVueRouterPath(segments)

Convert an array of parsed path segments into a full Vue Router 4 path string. Automatically determines hasSucceeding per segment so that mid-path catchalls use the restrictive ([^/]*)* pattern and terminal catchalls use (.*)*.

function toVueRouterPath(segments: ParsedPathSegment[]): string
import { parsePath, toVueRouterPath } from 'unrouting'

toVueRouterPath(parsePath(['users/[id]/posts.vue'])[0].segments)
// => '/users/:id()/posts'

toVueRouterPath(parsePath(['[...slug]/suffix.vue'])[0].segments)
// => '/:slug([^/]*)*/suffix'  (mid-path catchall auto-detected)

toVueRouterPath(parsePath(['prefix/[...slug].vue'])[0].segments)
// => '/prefix/:slug(.*)*'     (terminal catchall)

parsePath(filePaths, options?)

Parse file paths into segments. Standalone – does not build a tree.

function parsePath(filePaths: string[], options?: ParsePathOptions): ParsedPath[]

interface ParsedPath {
  file: string
  segments: ParsedPathSegment[]
  meta?: { modes?: string[], name?: string }
}

compileParsePath(options?)

Pre-compile parsing options into a reusable function. Useful in hot paths (e.g., dev server file watchers) where parsePath would otherwise reconstruct the same regexes on every call.

function compileParsePath(options?: ParsePathOptions): CompiledParsePath

interface CompiledParsePath {
  (filePaths: string[]): ParsedPath[]
}

Returns a callable with the same signature as parsePath (minus the options argument). The regexes for root stripping, extension matching, and mode detection are built once at compile time.

import { addFile, buildTree, compileParsePath, toVueRouter4 } from 'unrouting'

const opts = { roots: ['pages/'], modes: ['client', 'server'] }

// Compile once at startup
const parse = compileParsePath(opts)
const tree = buildTree(initialFiles, opts)

// In a file watcher callback — no regex re-compilation
addFile(tree, 'pages/new-page.vue', parse)
const routes = toVueRouter4(tree)

The compiled function can be passed directly to addFile as the options argument:

// These are equivalent, but the compiled version avoids re-building regexes:
addFile(tree, file, parse) // pre-compiled (fast)
addFile(tree, file, opts) // raw options (re-compiles each call)

parseSegment(segment, absolutePath?, warn?)

Parse a single filesystem segment into typed tokens. Useful for modules that need to parse custom paths (e.g., i18n locale-specific routes).

function parseSegment(
  segment: string,
  absolutePath?: string,
  warn?: (message: string) => void
): ParsedPathSegmentToken[]

// Token types: 'static' | 'dynamic' | 'optional' | 'catchall' |
//              'repeatable' | 'optional-repeatable' | 'group'

walkTree(tree, visitor)

Walk all nodes depth-first.

function walkTree(
  tree: RouteTree,
  visitor: (node: RouteNode, depth: number, parent: RouteNode | null) => void
): void

isPageNode(node)

Check if a node has files attached (page node vs structural node).

function isPageNode(node: RouteNode): boolean

How nesting works

The tree distinguishes between page nodes (have files) and structural nodes (directory-only, no files):

  • Page nodes create nesting boundaries – children get relative paths
  • Structural nodes collapse – their path segment is prepended to descendants
parent.vue + parent/child.vue
  → { path: '/parent', children: [{ path: 'child' }] }

parent/child.vue  (no parent.vue)
  → { path: '/parent/child' }  (structural 'parent' collapses)

index.vue promotes a structural directory into a page node:

users/index.vue + users/[id].vue
  → { path: '/users', file: 'users/index.vue', children: [{ path: ':id()' }] }

Route groups (name) are transparent – they don't affect paths or nesting, but are stored in meta.groups.

Development

  • Clone this repository
  • Enable Corepack using corepack enable
  • Install dependencies using pnpm install
  • Run interactive tests using pnpm dev

License

Made with ❤️

Published under MIT License.

About

Making filesystem routing universal

Resources

License

Code of conduct

Stars

Watchers

Forks

Sponsor this project

 

Contributors 3

  •  
  •  
  •