kit
kit copied to clipboard
feat: Primitives for i18n routing
Closes #11223 Closes #5703
This PR adds the routing-primitives necessary to implement any i18n routing strategy, including ones with translated slugs. It adds:
- A way to rewrite outgoing navigations (#11223)
- A way of rewriting incoming requests (#5703)
The API for interacting with these is open for debate. Currently they are implemented as "router hooks", where you can add function in hooks.router.js
, but that's just because they have got to go somewhere.
resolveDestination({ from , to }): URL
: Allows you to rewrite any outgoing navigation.
rewriteURL({ url }): URL
: Allows you to rewrite any incoming request and change which route get's resolved
Both hooks run on both client and server.
With these primitives, you can now implement i18n routing like so:
Why these primitives?
Before we can implement i18n routing we first need to agree on some requirements. I went with these two:
- No need to move anything in
src/routes
when opting in to i18n routing - No need to rewrite any existing navigation code (href, goto, redirect etc. )
The primitives I'm proposing directly follow from these requirements. If you look at the diagram above, you can clearly see them in action.
How do they work
resolveDestination
resolveDestination
get's applied to any outgoing navigation that goes through SvelteKit APIs. That includes:
-
href
,action
andformaction
attributes (implemented with preprocessor) -
goto
calls -
redirect
calls
It takes in the current url, and the destination url an returns a new destination url:
export const resolveDestination = ({ from, to }) => to;
rewriteURL
rewriteURL
get's applied before a routeID is resolved. It gives you the opportunity to change which route get's resolved. You could for example rewrite /en/about
to /about
, so that the routeID /about
will be used. It currently get's applied after static assets are resolved, so you can't use it to resolve a static file. That usecase is already covered with handle
.
It is roughly equivalent to NextJS's afterFiles
rewrite hook.
Example i18n routing implementation
I've created a few gists that implement different routing strategies using these primitives:
Addressing some Concerns
There are some valid concerns that I expect people to have. I'm going to preface them here.
It's unclear when resolveDestination
get's applied
I have shown this to a couple people, and they all quickly picked up on where resolveDestination
get's applied. It's all the stuff that goes through SvelteKit APIs. That means:
-
href
and other link attributes in markup -
goto
s -
redirects
Any direct network interaction with fetch
or manually created Response
objects remains unaffected.
The distinction quickly becomes intuitive.
It's too low level
The primitives admittedly are very low level, but I can't think of any higher level alternative that doesn't also severely limit which strategies can be implemented. If you have a higher level design that's just as flexible, please let me know.
You could mess up what's a data-request and what isn't
Yes, this is one of my concerns with the current implementation aswell. It's possible to misuse the rewriteURL
hook to turn data-requests into non-data requests, and vice-versa. It's worth considering having a rule that the "data-request" status is determined by the original URL, not the rewritten one.
Alternatives
I do believe that these two primitives are absolutely necessary for i18n routing, but the user-facing API could abstract them away. Many other frameworks offer different i18n routing strategies in their config. SvelteKit could do the same and use these primitives under the hood.
However, if that is done using translated slugs would suddenly become much harder, as SvelteKit would need to include it's own message-loading. The API surface would likely grow more than it would with just these primitives. I believe it's best to leave this abstraction to third-party libraries.
I opened this as a draft PR because I expect there to be some discussion & bugs. This probably won't get merged as-is.
Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
- [x] This message body should clearly illustrate what problems it solves.
- [x] Ideally, include a test that fails without this PR but passes with it.
Tests
- [x] Run the tests with
pnpm test
and lint the project withpnpm lint
andpnpm check
Changesets
- [x] If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running
pnpm changeset
and following the prompts. Changesets that add features should beminor
and those that fix bugs should bepatch
. Please prefix changeset messages withfeat:
,fix:
, orchore:
.
🦋 Changeset detected
Latest commit: c63b0d4be0a03b048b8c92339ada21f6c17f20d5
The changes in this PR will be included in the next version bump.
This PR includes changesets to release 1 package
Name | Type |
---|---|
@sveltejs/kit | Minor |
Not sure what this means? Click here to learn what changesets are.
Click here if you're a maintainer who wants to add another changeset to this PR
I'm not sure i understand, in your i18n example, does the about/+page.svelte
file lives under a [lang]
folder, or directly under the root folder ?
In the example it would be directly in the root folder. A [[lang]]
parameter is not needed with this approach.
Any incoming request to /de/about
gets rewritten to /about
. If you prefer having an explicit parameter, you of course can by omitting rewriteUrl
.
okay, and how do you determine the current language then ? from what i understand, the language info is never explicitly described in the url.
if we suppose a "translations" page on the svelte site listing the different translated versions, what would the href
on each link look like ?
The language would be part of the URL. That's the difference between a rewrite and a redirect. A rewrite doesn't change the URL, it just changes which route get's resolved.
If you have a rewriteURL
hook that rewrites requests to /fr/sur-nous
to /about
, then the URL would still be /fr/sur-nous
, but the route located at src/routes/about/+page.svelte
get's resolved. You can read event.url
or $page.url
and resolve the current language from there.
As for what you write in the href
, that's configurable with the resolveDestination
hook. The resolveDestination
hook allows you to edit the href
that's written on a page before the router starts navigating. You also have access to the current URL, so you can carry over the language from there.
For example you could have a resolveDestination
hook that rewrites the href /about
differently depending on the current URL.
Current URL | /about after resolveDestination |
---|---|
https://example.com | https://example.com/about |
https://example.com/de/eine/seite | https://example.com/de/ueber-uns |
https://example.fr | https://example.fr/sur-nous |
With this hook configured, you could continue writing "/about" as your href
attributes, but depending on your current URL (and the language contained within) it would link to different places.
Both these hooks are just slots where you can add your own free-form functions. All this is just a suggestion for how you could manage the language-state in the URL. You can implement whatever behaviour you like.
I've annotated the diagram to hopefully make it clearer what get's written and which values are what:
That's much clearer, i am not really used to this kind of logic yet, thx a lot for the detailed explanation!
I think it makes sense to keep it low level. I'm not sure yet if resolveDestination
should be added automagically, or if this should be a more manual effort. Doing it automagically will likely get this right in many cases but could also contribute to hard-to-detect bugs coming from multiple sources (goto called wrong, preprocessor not picking up a specific url or picking up a url it shouldnt). I'm wondering if it's time to introduce a specific href
helper utilty or maybe even a A
component which would take care of resolveDestination
and would also take care of prefixing urls with base
.
I'll be splitting this PR into two PRs, one for each hook. Once the new PRs are submitted I will close this one
I'm wondering if it's time to introduce a specific
href
helper utilty or maybe even aA
component which would take care ofresolveDestination
and would also take care of prefixing urls withbase
.
Instead of a preprocessor or a A
component, I think it would be enough if the resolveRoute
function from #11406 would take care of rewriting the "outgoing" urls.
Taking another look at this now that #11537 has landed.
To be honest I'm quite sceptical about resolveDestination
! It feels like way too much magic with way too many potential edge cases, and unlike reroute
it doesn't solve a problem that can't be solved (in a more explicit/debuggable way) in userland.
Given that, should we close this PR?
Thanks @LorisSigrist for your work in this space. I agree with Rich here. From the public interface perspective, having another resolveDestination
hook is somewhat confusing, given we already have before/after/onNavigation
. Please correct me if i'm wrong but the body of resolveDestination
in your example can be in beforeNavigate
, no?
For the record, here's what I currently have for sveltevietnam.dev...
/**
* If going from a localized url to a non-localized url,
* reroute to keep the lang segment. For example:
* navigate from `/en/blog` to `/events` will reroute to `/en/events`
*
* This allows all links in site to exclude lang segment but user
* still sees a consistent display language, unless they change it explicitly
*/
beforeNavigate(({ to, cancel, from }) => {
const fromLang = from?.params?.lang;
const toLang = to?.params?.lang;
if (to && fromLang && !toLang) {
cancel();
const localized = localizeUrl(to.url, fromLang, LANGUAGES);
goto(localized);
}
});
...coupled with some code in hooks.server
let languageFromUrl = getLangFromUrl(url, LANGUAGES);
const referer = request.headers.get('Referer');
if (referer) {
const urlReferer = new URL(referer);
if (urlReferer.origin === url.origin) {
locals.internalReferer = urlReferer;
}
}
if (!languageFromUrl) {
// if user comes from an internal link with lang, redirect to the same lang
// this is for progressive enhancement when JS is unavailable,
// otherwise the beforeNavigate hook in [[lang=lang]]/+layout.svelte will
// handle the redirection with kit client-side router
if (locals.internalReferer) {
languageFromUrl = getLangFromUrl(locals.internalReferer, LANGUAGES);
if (languageFromUrl) {
return Response.redirect(localizeUrl(url, languageFromUrl, LANGUAGES), 302);
}
}
}
Above setup allows me to omit all lang segment in gogo
, href
, ... Although, I haven't tested with subdomain-based i18n routing, so i'm not entirely sure if there's any problem there.
I had suspected that the resolveDestination
hook might be too magic, so I won't open a separate PR for it. It's functionality can also largely be implemented using a Preprocessor, which I plan on writing.
@vnphanquang I have tried that, but unfortunately it doesn't quite cut it. ThebeforeNavigate
approach won't work if JS is disabled.
Thanks for your feedback everyone, I'm very excited about the new reroute
hook!
I'll close this now
@LorisSigrist yeah in case JS is not available, that's when the hooks.server
code comes into play, but of course that assumes you can use hooks.server
. Anyway, a preprocessor is an interesting choice for this, I'm curious to know if it's possible. Please do share once you have a solution 😄