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

[builtin middleware] Accept Language (i18n?) #1897

Closed
sor4chi opened this issue Jan 4, 2024 · 15 comments · Fixed by #2001
Closed

[builtin middleware] Accept Language (i18n?) #1897

sor4chi opened this issue Jan 4, 2024 · 15 comments · Fixed by #2001
Labels
enhancement New feature or request.

Comments

@sor4chi
Copy link
Contributor

sor4chi commented Jan 4, 2024

What is the feature you are proposing?

For Hono MPA's DX improvement, how about providing the middleware that configures the language in option and gets judged language from c.var.lang

app.use('*', i18n({
  defaultLang: 'en',
  supportedLangs: ['en', 'ja', 'fr-CH'],
}))

app.get('/', (c) => c.text(c.var.lang))
@sor4chi sor4chi added the enhancement New feature or request. label Jan 4, 2024
@sor4chi sor4chi changed the title [builtin] Accept Language middleware (i18n?) [builtin middleware] Accept Language (i18n?) Jan 4, 2024
@sor4chi
Copy link
Contributor Author

sor4chi commented Jan 4, 2024

If there is more Accept Header type middleware in the future, it might be better to produce the parser internally.

like:

ja;q=0.8,en;q=0.7

@sor4chi
Copy link
Contributor Author

sor4chi commented Jan 4, 2024

related: #1792

@yusukebe
Copy link
Member

yusukebe commented Jan 9, 2024

@sor4chi

I understand that you want this feature. Besides, other frameworks have similar i18n features. But, I would like to know the specific use case of using i18n features when building an application with Hono.

@sor4chi
Copy link
Contributor Author

sor4chi commented Jan 10, 2024

@yusukebe

Sure.

For middleware path redirecting

For example, Vitepress recommends that when designing a directory split per Local, the middleware (proxy) layer should be configured to specify the lang and redirect.

https://vitepress.dev/guide/i18n#separate-directory-for-each-locale

const supportedLangs = ['en', 'ja']

app.use(
  '*',
  i18n({
    defaultLang: 'en',
    supportedLangs,
  })
)

app.get('/*', async (c) => {
  const path = c.req.path
  const firstPath = path.split('/')[1]
  if (supportedLangs.includes(firstPath)) {
    return await fetch(path)
  }
  return c.redirect(`/${c.var.lang}${path}`)
})

For MAP's Context (hono/jsx SSR)

import { createContext, useContext } from 'hono/jsx'

const MESSAGE_MAP = {
  en: {
    hello: 'Hello',
  },
  ja: {
    hello: 'こんにちは',
  },
}

const i18nContext = createContext({ lang: 'en' })

const TopPage = () => {
  const { lang } = useContext(i18nContext)

  return (
    <div>
      <h1>{MESSAGE_MAP[lang].hello}</h1>
    </div>
  )
}

app.use(
  '*',
  i18n({
    defaultLang: 'en',
    supportedLangs: ['en', 'ja'],
  })
)

app.get('/', async (c) => {
  return c.render(
    <i18nContext.Provider value={{ lang: c.var.lang }}>
      <TopPage />
    </i18nContext.Provider>
  )
})

@yusukebe
Copy link
Member

Hi @sor4chi

Thanks! I understood well.

This is my opinion, but rather than creating i18n, it would be better to create a helper that parses Accept, Accept-Language, or Accept-Encoding header.

The Issue mentioned by @humphd in #1792 can also be solved.

Like this:

import { matchAccept } from 'hono/accept-parser'

app.get('/welcome', (c) => {
  const mimeType = matchAccept(c, {
    header: 'accept',
    supports: [/^application\/json/, /^text\/html/],
  })
  if (mimeType.startsWith('application/json')) {
    return c.json({ message: 'Welcome!' })
  }
  if (mimeType.startsWith('text/html')) {
    return c.html('<h1>Welcome!</h1>')
  }
  // Fallback
  return c.text('Welcome!')
})

Using middleware pattern:

app.use('*', async (c, next) => {
  const lang = matchAccept(c, {
    header: 'accept-language',
    supports: ['en', 'ja', 'fr-CH'],
    default: 'en',
  })
  c.set('lang', lang)
  await next()
})

What do you think?

@sor4chi
Copy link
Contributor Author

sor4chi commented Jan 14, 2024

Hi, @yusukebe
Thanks for this suggestion.

Almost I agree.

As I mentioned in the beginning, I also thought the policy of creating a Parser for Accept looked good, so that would certainly be sufficient.
However, it seems that we need to consider cases where the order is not simple, such as when the weighting is based on quality value (https://developer.mozilla.org/ja/docs/Glossary/Quality_values).

If no one has worked on it yet, I'll do this!

@yusukebe
Copy link
Member

@sor4chi

Before starting implementation, how about investigating other frameworks to see if they have parsers for Accept headers?

@sor4chi
Copy link
Contributor Author

sor4chi commented Jan 14, 2024

Okay,

Express has req.accept* for parsing it and sort by quality internally.
https://expressjs.com/ja/api.html#req.accepts
スクリーンショット 2024-01-14 18 30 07

Koa has some simular interfaces with Express.
https://koajs.com/#request-accepts-types-

Fastify, Elysia, does not appear to have an internal parser.

@yusukebe
Copy link
Member

yusukebe commented Jan 14, 2024

Cool!

I don't have an idea right now, but we might make Accepts Middleware or Accepts Helper. If you have a good idea, please make a PR. Then, let's discuss it together.

@sor4chi
Copy link
Contributor Author

sor4chi commented Jan 14, 2024

I personally like this interface and would like to promote it.

const lang = matchAccept(c, {
    header: 'accept-language',
    supports: ['en', 'ja', 'fr-CH'],
    default: 'en',
})

According to the RFC, in addition to quality, there may be other modifiers (such as format and level).

https://www.rfc-editor.org/rfc/rfc9110.html#name-accept

Suggestion

How about providing a quality order matcher as the default, and allowing the user to decide on a custom matcher such as the following?

interface Accept {
  type: string
  mod: Record<string, string | number>
}

// text/html;level=1;q=0.5 => { type: 'text/html', mod: { level: '1', q: '0.5' } }

const lang = matchAccept(c, {
  header: 'accept-language',
  supports: ['en', 'ja', 'fr-CH'],
  default: 'en',
  match: (accepts: Accept[], config) => {
    // ...
  }
})

@yusukebe
Copy link
Member

@sor4chi

Seems to be good. What should we specify for the match option?

@sor4chi
Copy link
Contributor Author

sor4chi commented Jan 15, 2024

The return value of the match function is passed as it is as the return value of matchAccept.

@yusukebe
Copy link
Member

@sor4chi

Please show me the code.

@sor4chi
Copy link
Contributor Author

sor4chi commented Jan 15, 2024

const lang = matchAccept(c, {
  header: "accept-language",
  supports: ["en", "ja", "fr-CH"],
  default: "en",
  match: (accepts: Accept[], config) => {
    // In the original, sort in order of high quality and return the first match from among the supported languages
    // But this time, as a custom matcher, sort in order of low quality and return the first match from among the supported languages
    return accepts
      .sort((a, b) => (+a.mod.q > +b.mod.q ? 1 : -1))
      .find((accept) => config.supports.includes(accept.type));
  },
});

// input header:
//    Accept-Language: en;q=0.8, fr;q=0.5, fr-CH;q=0.9, ja;q=0.7
// output:
//    lang === 'fr'

The match handler is only for the user to select the corresponding type from the Accept header using arbitrary logic; if not set, the default quality order is used.
If not set, the default quality order is selected.

@yusukebe
Copy link
Member

@sor4chi

Okay! Please create the PR. Let's discuss it there.

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

Successfully merging a pull request may close this issue.

2 participants