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/"
-
Or doing the opposite, remove trailing slashes:
"/some-post/"
=>"/some-post"
-
Remove configuration queries starting withfrontity_
:
"/some-post/?frontity_name=my-site"
=>"/some-post/"
"/some-post/?frontity_publicPath=/other-static-folder"
=>"/some-post/"
This was already done at a framework level with the introduction of the “Frontity Query Options”: Auth header in Source packages and Frontity Query Options -
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 istrue
and will not run at all if the value isfalse
. - If it’s an object,
actions.router.set
will run the library specified inlibrary
and will passoptions
to the library. It can also specify apriority
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 withNot required as this is already done by the core.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.
This is not a good approach because sometimes the URL sent to the server can be different than the URL that exists in the client: https://github.com/frontity/frontity/issues/623
So we need to do the opposite, update state.router.link
with window.location
.
In the future, when we add Frontity hooks, these converters will be substituted by them.
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.