Router Converters

Description

Sometimes multiple URLs represent a single URL. We need a way to modify those URLs before they reach the rest of the framework.

The system needs to be extensible. Users and packages must be able to add and remove those modifications at will.

User Stories

As a Frontity user
I want to replace some URLs with a different URL
so that I don’t have to deal with duplicate URLs that shouldn’t exist.

Examples

  • Adding trailing slashes:
    "/some-post" => "/some-post/"
  • Remove configuration queries starting with frontity_:
    "/some-post/?frontity_name=my-site" => "/some-post/"
    "/some-post/?frontity_publicPath=/other-static-folder" => "/some-post/"
  • Replace search spaces (or %20) with +:
    "/?s=some search" => "/?s=some+search"
    "/?s=some%20search" => "/?s=some+search"
  • Replace search permalink with search query:
    "/search/some+search" => "/?s=some+search"

Possible Implementation

Introduce a new system called router converters, based on:

  • Configuration objects in state.router.converters.
  • Code in libraries.router.converters.

When the action actions.router.set is run, it will look in the state and will run the libraries before changing the URL.

Each converter can be declared by a boolean or an object in the state.

  • If it’s a boolean, actions.router.set will just run the library with the same name if the value is true and will not run at all if the value is false.
  • If it’s an object, actions.router.set will run the library specified in library and will pass options to the library. It can also specify a priority different than the default (10).

The options of each converter are decided by the converter creators and should be documented by them.

import { trailingSlash, removeQueries } from "./converters";

export default {
  state: {
    router: {
      converters: {
        trailingSlash: true,
        removeQueries: {
          library: "removeQueries",
          priority: 5,
          options: {
            blacklist: {
              frontity_name: true,
              frontity_publicPath: true,
            },
          },
        },
      },
    },
  },
  libraries: {
    router: {
      converters: {
        trailingSlash,
        removeQueries,
      },
    },
  },
};

If the configuration object is false the converter will not be used. This is useful to deactivate converters on demand.

By the user, using frontity.settings.js:

export default {
  // ...
  packages: [
    {
      name: "tiny-router",
      state: {
        router: {
          converters: {
            trailingSlash: false,
          },
        },
      },
    },
  ],
};

By some other package, using its own state. For example, imagine somebody wants to remove trailing slashes instead of adding them:

import { removeTrailingSlash } from "./router-converters";

export default {
  state: {
    router: {
      converters: {
        trailingSlash: false,
        removeTrailingSlash: true,
      },
    },
  },
  libraries: {
    router: {
      converters: {
        removeTrailingSlash,
      },
    },
  },
};

The action actions.router.set may look something like this:

const set = ({ state, libraries }) => (link, options) => {
  Object.entries(state.router.converters)
    // Filter false converters.
    .filter(([_, converter]) => converter !== false)
    // Add default priority.
    .map(([name, converter]) => (converter.priority = converter.priority || 10))
    // Sort them by priority.
    .sort(
      ([_, converterA], [_, converterB]) =>
        converterA.priotity > converterB.priority
    )
    .forEach(([name, converter]) => {
      // The library used is either the key or the `library` prop.
      const codeConverterName = conveter === true ? name : converter.library;
      const codeConverter = libraries.router.converters[codeConverterName];
      // Run the code and replace link.
      link = codeConverter({
        state,
        libraries,
        link,
        options: { ...options, ...converter.options },
      });
    });
  // Continue with actions.router.set duties...
};

Code converters will receive the link, the options passed to actions.router.set (like method) and they will have access to state and libraries:

const removeQueries = ({ link, options }) => {
  // Generate URL object.
  const url = new URL(link);
  // Remove the queries that are in the blacklist options.
  Array.from(url.searchParams).forEach((key) => {
    if (Object.keys(options.blacklist).includes(key))
      url.searchParams.delete(key);
  });
  // Return the filtered URL.
  return `${url.pathname}${url.search}`;
};

Users and package creators can also modify the options of the converters.

For example, a package may add an additional configuration query that needs to be filtered. Imagine a package needs to filter ?my_package_option=XXX.

import { removeQueries } from "@frontity/router/converters";

export default {
  state: {
    router: {
      converters: {
        removeQueries: {
          library: "removeQueries",
          options: {
            blacklist: {
              my_package_option: true,
            },
          },
        },
      },
    },
  },
  libraries: {
    router: {
      converters: {
        removeQueries,
      },
    },
  },
};

One important thing to notice is that, in order to make sure that the query is going to be removed, the package needs to import the library and recreate the converter configuration object itself. If the package doesn’t do that and rely on the "removeQueries" to be there it will work only if another package has added the "removeQueries" converter, which is not a solid behavior.

This will fail if no other package is adding the "removeQueries" code converter.

export default {
  state: {
    router: {
      converters: {
        removeQueries: {
          options: {
            blacklist: {
              my_package_option: true,
            },
          },
        },
      },
    },
  },
};

Default converters

By default, @frontity/tiny-router can add:

  • Add trailing slash converter.
  • Remove any query that starts with frontity_.

By default, @frontity/wp-source can add:

  • Replace search query spaces with +.
  • Replace permalink search with query search.

It’s important to notice that the converters relevant only to WordPress should be added by WordPress related packages and @frontity/tiny-router is agnostic to WordPress.

Update link on client hydration

We should update the browser URL on client hydration to match state.router.link. We can do so with history.replaceState.

This is not strictly necessary as any Frontity package should be using state.router.link instead of window.location, but I guess for consistency it’d be good to make them match.


In the future, when we add middleware to connect, these converters will be substituted by action middleware.

Dependencies

There is another FD to add an options object to actions.router.set: Router set options: method, title and state. This FD doesn’t depend on that feature to work, but if this one is done before, it needs to be modified to pass the options object to the converters once that FD is implemented.

Due to our collaboration with react-easy-state I think we should not go ahead with this Feature Discussion as it is. Instead, wait until we have action hooks.

Thanks to action hooks, we will be able to achieve the same result/pattern explained here, but done with a tool that works for any Frontity action, not something specific to actions.router.set.

I agree action hooks is a best approach, I really like the idea. I’m closing this Feature Discussion and we can open it again in case we finally implement it this way.