Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Primitives for i18n routing #11396

Closed

Conversation

LorisSigrist
Copy link
Contributor

Closes #11223
Closes #5703

This PR adds the routing-primitives necessary to implement any i18n routing strategy, including ones with translated slugs. It adds:

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:
Screenshot 2023-12-18 at 17 46 28

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 and formaction 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
  • gotos
  • 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:

  • 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
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests

  • Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • 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 be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.

Copy link

changeset-bot bot commented Dec 19, 2023

🦋 Changeset detected

Latest commit: c63b0d4

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

@bleucitron
Copy link
Contributor

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 ?

@LorisSigrist
Copy link
Contributor Author

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.

@bleucitron
Copy link
Contributor

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 ?

@LorisSigrist
Copy link
Contributor Author

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:
Screenshot 2023-12-29 at 11 13 23

@bleucitron
Copy link
Contributor

That's much clearer, i am not really used to this kind of logic yet, thx a lot for the detailed explanation!

@dummdidumm
Copy link
Member

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.

@LorisSigrist
Copy link
Contributor Author

I'll be splitting this PR into two PRs, one for each hook. Once the new PRs are submitted I will close this one

@LorisSigrist LorisSigrist mentioned this pull request Jan 8, 2024
5 tasks
@PatrickG
Copy link
Member

PatrickG commented Jan 8, 2024

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.

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.

@Rich-Harris
Copy link
Member

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?

@vnphanquang
Copy link
Contributor

vnphanquang commented Jan 11, 2024

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.

@LorisSigrist
Copy link
Contributor Author

LorisSigrist commented Jan 11, 2024

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

@vnphanquang
Copy link
Contributor

@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 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

A way to Rewrite outgoing Navigations Support for rewrites
6 participants