headless-route
TypeScript icon, indicating that this package has built-in type declarations

2.5.0 • Public • Published

Headless Route

Generate routes for a Multi-Page Application (MPA) based on the file structure of a directory. It offers functions to create routes and navigation routes from the directory structure, allowing for easy navigation and dynamic routing.

Install

To use headless-route in your project, you can install it via npm or yarn:

npm i -D headless-route
# or
yarn add -D headless-route

Usage

Say we have the following directory structure. Refer to the example directory for further details:

./
├── pages/
│   ├── 404.md                # => /404.html
│   ├── about.md              # => /about.html
│   ├── blogs/
│   │   ├── api.js            # => the blogs api
│   │   └── :slug.md          # => /blogs/:slug
│   ├── contact.md            # => /contact.html
│   ├── _hidden/
│   │   └── hidden-page.md
│   └── index.md              # => /
├── ...
├── package-lock.json
└── package.json

▸ Create routes based on a directory structure:

import { createRoutes } from 'headless-route'
// Or for CommonJS:
// const { createRoutes } = require('headless-route')

const routes = await createRoutes({
  dir: 'pages',
  extensions: ['.html', '.md'],
  urlSuffix: '.html',
  filter(file) {
    // ignore files starting with '_'
    return !file.name.startsWith('_')
  },
  async handler(route) {
    if (route.isDynamic) {
      const dirname = route.id.split('/').slice(0, -1).join('/')
      const apifile = `./${dirname}/api.js`
      const { fetchApi } = await import(apifile)

      Object.assign(route, { context: await fetchApi() })
    }
  }
})
// for sync api:
// const routes = createRoutesSync({...})

console.log(routes)
Yields:
;[
  {
    id: 'pages/404.md',
    stem: '404',
    url: '/404.html',
    index: false,
    isDynamic: false
  },
  {
    id: 'pages/about.md',
    stem: 'about',
    url: '/about.html',
    index: false,
    isDynamic: false
  },
  {
    id: 'pages/blogs/$slug.md',
    stem: 'blogs/:slug',
    url: '/blogs/:slug.html',
    index: false,
    isDynamic: true,
    context: { foo: [Object], bar: [Object] }
  },
  {
    id: 'pages/contact.md',
    stem: 'contact',
    url: '/contact.html',
    index: false,
    isDynamic: false
  },
  {
    id: 'pages/foo/bar/baz/index.md',
    stem: 'foo/bar/baz/index',
    url: '/foo/bar/baz/index.html',
    index: true,
    isDynamic: false
  },
  {
    id: 'pages/foo/bar/index.md',
    stem: 'foo/bar/index',
    url: '/foo/bar/index.html',
    index: true,
    isDynamic: false
  },
  {
    id: 'pages/foo/index.md',
    stem: 'foo/index',
    url: '/foo/index.html',
    index: true,
    isDynamic: false
  },
  {
    id: 'pages/index.md',
    stem: 'index',
    url: '/index.html',
    index: true,
    isDynamic: false
  }
]

▸ Create navigation routes from routes:

import { createNavigation } from 'headless-route'
// Or for CommonJS:
// const { createNavigation } = require('headless-route')

const navigationRoutes = await createNavigation(routes)
// for sync api:
// const navigationRoutes = createNavigationSync(routes)

console.log(navigationRoutes)
Yields:
;[
  {
    stem: '404',
    url: '/404.html',
    index: false,
    isDynamic: false
  },
  {
    stem: 'about',
    url: '/about.html',
    index: false,
    isDynamic: false
  },
  {
    stem: 'blogs',
    url: '/blogs',
    index: true,
    isDynamic: false,
    children: [
      {
        stem: 'blogs/:slug',
        url: '/blogs/:slug.html',
        index: false,
        isDynamic: true,
        context: {
          foo: [Object],
          bar: [Object]
        }
      }
    ]
  },
  {
    stem: 'contact',
    url: '/contact.html',
    index: false,
    isDynamic: false
  },
  {
    stem: 'foo',
    url: '/foo',
    index: true,
    isDynamic: false,
    children: [
      {
        stem: 'foo/bar',
        url: '/foo/bar',
        index: true,
        isDynamic: false,
        children: [
          {
            stem: 'foo/bar/baz',
            url: '/foo/bar/baz',
            index: true,
            isDynamic: false,
            children: [
              {
                stem: 'foo/bar/baz/index',
                url: '/foo/bar/baz/index.html',
                index: true,
                isDynamic: false
              }
            ]
          },
          {
            stem: 'foo/bar/index',
            url: '/foo/bar/index.html',
            index: true,
            isDynamic: false
          }
        ]
      },
      {
        stem: 'foo/index',
        url: '/foo/index.html',
        index: true,
        isDynamic: false
      }
    ]
  },
  {
    stem: 'index',
    url: '/index.html',
    index: true,
    isDynamic: false
  }
]

[!NOTE] In navigation routes, a file named index serves as a Layout routes. It participates in UI nesting, but it does not add any segments to the URL.

▸ Finds a route that matches the provided request URL:

import { findRoute } from 'headless-route'

const requestUrl = '/blogs/foo.html'
const route = findRoute(requestUrl, routes)

if (route?.isDynamic) {
  // match params
  const params = route.matchParams(requestUrl)
  // yields: { slug: foo }

  // generate url path
  const urlpath = route.generatePath({ slug: 'bar' })
  // yields: /blogs/bar.html
}

Best practices

When structuring your project, adhere to the following best practices:

▸ Files or directories starting with an underscore character (_) should be ignored:

const routes = await createRoutes({
  filter(file) {
    // ignore files starting with '_'
    return !file.name.startsWith('_')
  }
})

▸ File or directory names starting with a dollar character ($) or colon (:), or conclude with a question mark (?), or are enclosed within square brackets ([]), will be treated as “dynamic segments”.

[!CAUTION] Please note that the colon (:) and question mark (?) characters are invalid for file names on Windows.

▸ Dynamic segments should adhere to the following formatting guidelines:

  • 🚫 Avoid: /users-:id.md (partial paths should be avoided)
  • ✅ Prefer: /users/:id.md or /users/$id.md
  • ✅ Acceptable: /users/:id?.md or /users/[id].md (for optional segments)
  • 🚫 Avoid: /posts/:categories--:id.md (partial paths should be avoided)
  • ✅ Prefer: /posts/:categories/:id.md or /posts/$categories/$id.md
  • ✅ Acceptable: /posts/[lang]/categories.md (for optional segments)
  • ✅ Acceptable: /files/*.md (for splat segments)
  • ✅ Acceptable: /foo/:bar*.md (for named splat segments)
  • ✅ Acceptable: /foo/:bar+.md (for required splat segments)

▸ Follow a consistent pattern in CRUD operations. Instead of naming files like foo/$id.edit.tsx, use foo/$id/edit.tsx:

🚫 Avoid:

  • pages/users/$id.create.tsx
  • pages/users/$id.edit.tsx
  • pages/users/$id.delete.tsx
  • pages/users/$id.view.tsx

✅ Prefer:

  • pages/users/$id/create.tsx
  • pages/users/$id/edit.tsx
  • pages/users/$id/delete.tsx
  • pages/users/$id/view.tsx
  • pages/users/api.ts
  • pages/users/index.tsx

API

createRoutes(options: Options): Promise<Route[]>

Creates routes based on the specified options:

  • dir: The directory to scan for routes. Defaults to the current working directory (process.cwd()).

  • extensions: The file extensions to include when scanning for routes. Defaults (['.html', '.md']).

  • urlPrefix: Defines the prefix to prepend to route URLs. Defaults '/'.

    Acceptable values include:

    • Absolute URL pathname, e.g., /foo/
    • Full URL, e.g., https://foo.com/
    • Empty string or ./
  • urlSuffix: Defines the suffix to append to route URLs. Defaults ''.

  • cache: Indicates whether to cache routes. Defaults to false.

  • filter: A filter function for filtering Dirent objects. It automatically disregards files and directories listed in the project's .gitignore file, ensuring they are consistently excluded from consideration.

    const routes = await createRoutes({
      filter(file) {
        // ignore files starting with '_' or ending with '.data.js'
        return !file.name.startsWith('_') && !file.name.endsWith('.data.js')
      }
    })
  • handler: A handler function called for each route.

    await createRoutes({
      dir: 'pages',
      async handler(route) {
        if (route.id.endsWith('.js')) {
          // attach a lazy route for JavaScript files
          route.lazy = import(route.id)
        }
      }
    })

createRoutesSync(options: OptionsSync): Route[]

Creates routes based on the specified options synchronously.

createNavigation(routes: Route[], handler?: NavigationHandlerFn): Promise<NavigationRoute[]>

Creates navigation routes based on the specified routes. A navigation route object has the same structure as a route object, excluding the id property. It may also contain children property, representing the children routes of the navigation route.

  • routes: An array of routes.
  • handler?: A navigation route handler function.
const navigationRoutes = await createNavigation(routes, route => {
  const segments = route.stem.split('/')
  const lastSegment = String(segments.pop())

  // assign 'text' prop for each route and layout routes
  Object.assign(route, {
    text: lastSegment[0].toUpperCase() + lastSegment.slice(1).toLowerCase()
  })
})

createNavigationSync(routes: Route[], handler?: NavigationHandlerFnSync): NavigationRoute[]

Creates navigation routes based on the specified routes synchronously.

createRoute(id: string, options: { root: string, urlPrefix: string }): Route

A utility to create a route object based on the provided ID and options.

import { createRoute } from 'headless-route'

const route = createRoute('pages/users/:id.md', {
  root: 'pages',
  urlSuffix: '.html'
})

// Yields:
// { id: 'pages/users/$id.md', stem: 'users/:id', url: '/users/:id.html', index: false, isDynamic: true }

findRoute(requestUrl: string, routes: Route[]): Route | undefined

A utility to Find a route that matches the provided request URL.

import { findRoute } from 'headless-route'

const matchedRoute = findRoute('/contact.html', routes)

// Yields:
// { id: 'pages/contact.md', stem: 'contact', url: '/contact.html', index: false, isDynamic: false }

routeSegments(id: string, root?: string): string[]

A utility to extract segments from a route id relative to a root directory.

import { routeSegments } from 'headless-route'

const segments = routeSegments('foo/bar/baz.html', 'foo')

// Yields:
// ['bar', 'baz']

Types

Route

Represents a single route in the MPA.

Types:
/**
 * Represents a route, which can be either a base route or a dynamic route.
 */
export type Route = BaseRoute | DynamicRoute

/**
 * Represents the base structure of a route.
 */
export interface BaseRoute {
  /**
   * The unique identifier for the route.
   */
  id: string

  /**
   * The stem of the route URL.
   */
  stem: string

  /**
   * The URL of the route.
   */
  url: string

  /**
   * Indicates whether the route is an index page.
   */
  index: boolean

  /**
   * Indicates whether the route is dynamic.
   */
  isDynamic: false
}

/**
 * Represents a dynamic route, which can match and generate URLs dynamically.
 */
export interface DynamicRoute extends Omit<BaseRoute, 'isDynamic'> {
  /**
   * Indicates whether the route is dynamic.
   */
  isDynamic: true

  /**
   * Function to check if the given input matches the route.
   *
   * @param input The input to match against the route.
   * @returns A boolean indicating whether the input matches the route.
   */
  isMatch: (input: string) => boolean

  /**
   * Function to extract parameters from the given input if it matches the route.
   *
   * @template Params The type of parameters extracted from the input.
   * @param input The input to extract parameters from.
   * @returns The extracted parameters if the input matches the route, otherwise false.
   */
  matchParams: <Params extends object = object>(input: string) => false | Params

  /**
   * Function to generate a URL using the provided parameters.
   *
   * @template Params The type of parameters used to generate the URL.
   * @param params The parameters used to generate the URL.
   * @returns The generated URL.
   */
  generatePath: <Params extends object = object>(params: Params) => string
}

NavigationRoute

Represents a navigation route with additional data. It inherits all properties from Route except for id.

Types:
/**
 * Represents a navigation route, which extends the base route structure and
 * can have children routes.
 */
export interface NavigationRoute extends Omit<Route, 'id'> {
  /**
   * Children routes of the navigation route.
   */
  children?: NavigationRoute[]
}

Related

Contributing

We 💛  issues.

When committing, please conform to the semantic-release commit standards. Please install commitizen and the adapter globally, if you have not already.

npm i -g commitizen cz-conventional-changelog

Now you can use git cz or just cz instead of git commit when committing. You can also use git-cz, which is an alias for cz.

git add . && git cz

License

GitHub

A project by Stilearning © 2024.

Package Sidebar

Install

npm i headless-route

Weekly Downloads

14

Version

2.5.0

License

MIT

Unpacked Size

79.3 kB

Total Files

15

Last publish

Collaborators

  • bent10