nuxt-modules / i18n

I18n module for Nuxt
https://i18n.nuxtjs.org
MIT License
1.75k stars 483 forks source link

Support typed routes inside localePath() #2813

Closed AndreyYolkin closed 1 month ago

AndreyYolkin commented 9 months ago

Describe the feature

According to the code here, nuxt i18n imports types from vue-router package: https://github.com/nuxt-modules/i18n/blob/c43842f11027253c07c45b7a9e8e6ab81bae7d54/src/runtime/composables/index.ts#L24

However, nuxt itself registers #vue-router alias to handle both situations with enabled/disabled typed routes. Example of usage: https://github.com/nuxt/nuxt/blob/d326e054d372bd2eb5bf75f3feca6a291169ff76/packages/nuxt/src/pages/runtime/utils.ts#L2

Since nuxt/i18n is a nuxt module, I suppose we can rely on this alias too and enable type imports by replacing vue-router with #vue-router for types imports

Additional information

Final checks

AndreyYolkin commented 9 months ago

As workaround, I added this snippet inside d.ts file and it works:

/// <reference types="unplugin-vue-router/client" />
import type { RouteLocation, RouteLocationNormalizedLoaded, RouteLocationRaw, Router } from '#vue-router';

declare module '#i18n' {
  export type StubbedLocalePathFunction = (route: RouteLocation | RouteLocationRaw, locale?: Locale) => string;

  export declare function useLocalePath(): StubbedLocalePathFunction;
}
Anoesj commented 1 month ago

Thanks @AndreyYolkin! I modified it a bit, because lately vue-router started providing typed routes by itself. Your stub wasn't working as is for me, because nuxt-i18n suffixes route names with ___<locale>. Hopefully this is useful to anyone needing this.

utils/locales.ts:

export const locales = [
  'nl',
  'en',
  'fr',
  'de',
] as const;

export type AppLocale = typeof locales[number];

/typings/i18n.d.ts:

import type { RouteNamedMap } from 'vue-router/auto-routes';
import type { RouteLocation, RouteLocationRaw } from 'vue-router';

type RouteName = keyof RouteNamedMap;
type WithoutLocale<T extends string> = T extends `${infer Base}___${AppLocale}` ? Base : T;

type OriginalRouteDefinition<T extends RouteBaseName> = RouteNamedMap[`${T}___${AppLocale}`];

type ReplaceParamsInPath<
  Path extends string,
  Params extends Record = Record<PropertyKey, never>,
> = Params extends Record<PropertyKey, never>
  ? Path
  : Path extends `${infer Before}:${infer Param}()${infer Rest}`
    ? ReplaceParamsInPath<`${Before}${Params[Param]}${Rest}`, Omit<Params, Param>>
    : Path;

declare global {
  type RouteBaseName = WithoutLocale<RouteName>;
}

declare module '#i18n' {
  // This makes sure `useLocalePath` uses the typed router, but with every
  // route's base name, instead of the `___{locale}` suffixed one.
  type StubbedUseLocalePathFunction = <
    RouteName,
    Locale extends AppLocale = AppLocale,
    RouteDefinition = OriginalRouteDefinition<RouteName, Locale>,
    const Params = RouteDefinition['params'],
  >(
    route: RouteName extends RouteBaseName
      ? (RouteName | {
        name: RouteName;
        params?: RouteDefinition['params'];
      })
      : (RouteLocation | RouteLocationRaw),
    locale?: Locale
  ) => RouteName extends RouteBaseName
    ? ReplaceParamsInPath<RouteDefinition['path'], Params>
    : string;

  export function useLocalePath (): StubbedLocalePathFunction;
}

It would indeed be very useful to have this built in, so no stubs are needed. It seems to me however, that this might become quite complicated, as vue-router's internal typed router types seem to be quite entangled with the RouteNamedMap type. It might need to become a joint effort with the vue-router team to make this work.

BobbieGoede commented 1 month ago

A collaborative route would be the best, with the current state of Nuxt and Vue Router we would need to reimplement type generation and rely on internal types, which could be changed at any time.

I do have a branch that has these types generated, I might implement it at some point under an experimental flag but with no promise that it will keep on working, you can check it out here https://github.com/BobbieGoede/i18n/pull/49.

@Anoesj

export const locales = [ 'nl', 'en', 'fr', 'de', ] as const;

In v9 we've added a generated Locale type based on all merged configs, should make your types a little bit easier to maintain. Will be publishing a release candidate in the coming weeks (probably).

Anoesj commented 1 month ago

Oh that's nice, I see you're way ahead of me! Looking forward to it! :smile:

BobbieGoede commented 1 month ago

Here's a demo using a preview build (the types only work in the typescript files cause of stackblitz): https://stackblitz.com/edit/bobbiegoede-nuxt-i18n-starter-srohiz?file=nuxt.config.ts&file=composables%2Ftest.ts

There's other stuff not quite working yet, you can track its progress in #3142 and chime in with any feedback!

Anoesj commented 1 month ago

Nice start! Shouldn't params be required in the example below, since [slug].vue is not catch-all, and the slug param therefore required? Once you uncomment that line, it actually does require you to provide the slug param, but it should also require params altogether if the route has non-optional route params.

const test = localePath({
    name: 'test-slug',
    // params: {}
  });
BobbieGoede commented 1 month ago

@Anoesj I thought so too, but the same happens with the types of vue-router, see https://stackblitz.com/edit/github-woay8w?file=composables%2Ftest.ts.

Anoesj commented 1 month ago

Hmm yeah, it does result in a runtime error though. That's not very nice. People use TypeScript to prevent runtime errors. I wouldn't mind nuxt-i18n to be a little more opiniated about this. Maybe raise this as an issue in the vue-router repo?

BobbieGoede commented 1 month ago

Oh I wasn't even aware it threw a runtime error (too focused on the types right now 😅)

I would expect there already being an issue open about this 🤔 I can open one if there isn't one (or if you would like to do so feel free and link back here).

Anoesj commented 1 month ago

https://github.com/vuejs/router/issues/2372

EDIT: There already seems to be an issue for this: https://github.com/posva/unplugin-vue-router/issues/285

Anoesj commented 1 month ago

Ah, it's actually not always wrong to omit params. Let's say you have the following routes in a Nuxt app:

In the component that renders /user/anoesj, you can use:

<RouterLink :to="{ name: 'user-id-settings' }">
  ⚙️ {{ $route.params.id }}'s settings
</RouterLink>

Here, we can omit the params without causing a runtime error, as vue-router will then automatically use any params of the current route that have the same name as the target route. In this case, both routes have an id param, so it'll automatically fall back to anoesj and link to /users/anoesj/settings.

In fact, it doesn't look at the hierarchy of the routes, it just looks for params in the current route with the same name as params in the target route and automatically fall back on the current route's param values.

See https://github.com/posva/unplugin-vue-router/issues/285:

[...] doing router.push({ name: 'route-name' }) for a route like { name: 'route-name', path: '/route/:id' } is valid and can work depending on where you are. This feature might disappear in major versions of Vue Router [...]

BobbieGoede commented 1 month ago

I have just published the first release candidate for v9 which includes the experimental typed routes experimental.typedPages.

Try it out and please open new issues if you experience any with this feature 🙏