From 7f33b18a3f1d9490e435884554cf530eb78f4261 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 6 Dec 2023 14:17:09 +0900 Subject: [PATCH] feat: support try locale getting APIs (#38) --- README.md | 5 ++ bun.lockb | Bin 310391 -> 310391 bytes deno/web.ts | 120 ++++++++++++++++++++++++++++++++++ src/h3.test.ts | 166 +++++++++++++++++++++++++++++++++++++++++++++++ src/h3.ts | 120 ++++++++++++++++++++++++++++++++++ src/hono.test.ts | 128 ++++++++++++++++++++++++++++++++++++ src/hono.ts | 120 ++++++++++++++++++++++++++++++++++ src/node.test.ts | 123 +++++++++++++++++++++++++++++++++++ src/node.ts | 122 ++++++++++++++++++++++++++++++++++ src/web.test.ts | 84 ++++++++++++++++++++++++ src/web.ts | 120 ++++++++++++++++++++++++++++++++++ 11 files changed, 1108 insertions(+) diff --git a/README.md b/README.md index 18e3245..9f845d4 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,11 @@ You can do `import { ... } from '@intlify/utils'` the above utilities - `setCookieLocale` - `getPathLocale` - `getQueryLocale` +- `tryHeaderLocales` +- `tryHeaderLocale` +- `tryCookieLocale` +- `tryPathLocale` +- `tryQueryLocale` The about utilies functions accpet Web APIs such as [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) that is supported by JS environments (such as Deno, Bun, and Browser) diff --git a/bun.lockb b/bun.lockb index 3d6adea5a3dea181829d015d56179db0a79c60bc..68b8833665ab738280a04dfd0c979371146635df 100755 GIT binary patch delta 6422 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 KroZpGr~D5&xIPmA diff --git a/deno/web.ts b/deno/web.ts index 954a5ab..fda08f1 100644 --- a/deno/web.ts +++ b/deno/web.ts @@ -111,6 +111,8 @@ export function getHeaderLanguage( * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. * + * @throws {RangeError} Throws the {@link RangeError} if header are not a well-formed BCP 47 language tag. + * * @returns {Array} The locales that wrapped from header, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. */ export function getHeaderLocales( @@ -126,6 +128,31 @@ export function getHeaderLocales( }) } +/** + * try to get locales from header + * + * @description wrap language tags with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. Unlike {@link getHeaderLocales}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Request} request The {@link Request | request} + * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. + * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. + * + * @returns {Array | null} The locales that wrapped from header, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. if header are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryHeaderLocales( + request: Request, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): Intl.Locale[] | null { + try { + return getHeaderLocales(request, { name, parser }) + } catch { + return null + } +} + /** * get locale from header * @@ -165,6 +192,33 @@ export function getHeaderLocale( return getLocaleWithGetter(() => getHeaderLanguages(request, { name, parser })[0] || lang) } +/** + * try to get locale from header + * + * @description wrap language tag with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. Unlike {@link getHeaderLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Request} request The {@link Request | request} + * @param {string} options.lang The default language tag, Optional. default value is `en-US`. You must specify the language tag with the {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 syntax}. + * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. + * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. + * + * @returns {Intl.Locale | null} The first locale that resolved from header string. if you use `accept-language` header and `*` (any language) or empty string is detected, return `en-US`. if `lang` option or header are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryHeaderLocale( + request: Request, + { + lang = DEFAULT_LANG_TAG, + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions & { lang?: string } = {}, +): Intl.Locale | null { + try { + return getHeaderLocale(request, { lang, name, parser }) + } catch { + return null + } +} + /** * get locale from cookie * @@ -203,6 +257,28 @@ export function getCookieLocale( return getLocaleWithGetter(getter) } +/** + * try to get locale from cookie + * + * @description Unlike {@link getCookieLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Request} request The {@link Request | request} + * @param {string} options.lang The default language tag, default is `en-US`. You must specify the language tag with the {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 syntax}. + * @param {string} options.name The cookie name, default is `i18n_locale` + * + * @returns {Intl.Locale | null} The locale that resolved from cookie. if `lang` option or cookie name value are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryCookieLocale( + request: Request, + { lang = DEFAULT_LANG_TAG, name = DEFAULT_COOKIE_NAME } = {}, +): Intl.Locale | null { + try { + return getCookieLocale(request, { lang, name }) + } catch { + return null + } +} + /** * set locale to the response `Set-Cookie` header. * @@ -264,6 +340,28 @@ export function getPathLocale( return _getPathLocale(new URL(request.url), { lang, parser }) } +/** + * try to get the locale from the path + * + * @description Unlike {@link getPathLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Request} request the {@link Request | request} + * @param {PathOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {PathOptions['parser']} options.parser the path language parser, default {@link pathLanguageParser}, optional + * + * @returns {Intl.Locale | null} The locale that resolved from path. if the language in the path, that is not a well-formed BCP 47 language tag, return `null`. + */ +export function tryPathLocale( + request: Request, + { lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {}, +): Intl.Locale | null { + try { + return getPathLocale(request, { lang, parser }) + } catch { + return null + } +} + /** * get the locale from the query * @@ -282,6 +380,28 @@ export function getQueryLocale( return _getQueryLocale(new URL(request.url), { lang, name }) } +/** + * try to get the locale from the query + * + * @description Unlike {@link getQueryLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Request} request the {@link Request | request} + * @param {QueryOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {QueryOptions['name']} options.name the query param name, default `'locale'`. optional + * + * @returns {Intl.Locale | null} The locale that resolved from query. if the language in the query, that is not a well-formed BCP 47 language tag, return `null`. + */ +export function tryQueryLocale( + request: Request, + { lang = DEFAULT_LANG_TAG, name = 'locale' }: QueryOptions = {}, +): Intl.Locale | null { + try { + return getQueryLocale(request, { lang, name }) + } catch { + return null + } +} + /** * get navigator languages * diff --git a/src/h3.test.ts b/src/h3.test.ts index ad47743..2158d4e 100644 --- a/src/h3.test.ts +++ b/src/h3.test.ts @@ -10,6 +10,11 @@ import { getPathLocale, getQueryLocale, setCookieLocale, + tryCookieLocale, + tryHeaderLocale, + tryHeaderLocales, + tryPathLocale, + tryQueryLocale, } from './h3.ts' import { parseAcceptLanguage } from './shared.ts' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' @@ -219,6 +224,37 @@ describe('getHeaderLocales', () => { }) }) +describe('tryHeaderLocales', () => { + test('success', () => { + const mockEvent = { + node: { + req: { + method: 'GET', + headers: { + 'accept-language': 'en-US,en;q=0.9,ja;q=0.8', + }, + }, + }, + } as H3Event + expect(tryHeaderLocales(mockEvent)!.map((locale) => locale.baseName)) + .toEqual(['en-US', 'en', 'ja']) + }) + + test('failed', () => { + const mockEvent = { + node: { + req: { + method: 'GET', + headers: { + 'accept-language': 'hoge', + }, + }, + }, + } as H3Event + expect(tryHeaderLocales(mockEvent)).toBeNull() + }) +}) + describe('getHeaderLocale', () => { test('basic', () => { const mockEvent = { @@ -308,6 +344,41 @@ describe('getHeaderLocale', () => { }) }) +describe('tryHeaderLocale', () => { + test('success', () => { + const mockEvent = { + node: { + req: { + method: 'GET', + headers: { + 'accept-language': 'en-US,en;q=0.9,ja;q=0.8', + }, + }, + }, + } as H3Event + const locale = tryHeaderLocale(mockEvent)! + + expect(locale.baseName).toEqual('en-US') + expect(locale.language).toEqual('en') + expect(locale.region).toEqual('US') + }) + + test('failed', () => { + const mockEvent = { + node: { + req: { + method: 'GET', + headers: { + 'accept-language': 's', + }, + }, + }, + } as H3Event + + expect(tryHeaderLocale(mockEvent)).toBeNull() + }) +}) + describe('getCookieLocale', () => { test('basic', () => { const mockEvent = { @@ -388,6 +459,41 @@ describe('getCookieLocale', () => { }) }) +describe('tryCookieLocale', () => { + test('success', () => { + const mockEvent = { + node: { + req: { + method: 'GET', + headers: { + cookie: `${DEFAULT_COOKIE_NAME}=en-US`, + }, + }, + }, + } as H3Event + const locale = tryCookieLocale(mockEvent)! + + expect(locale.baseName).toEqual('en-US') + expect(locale.language).toEqual('en') + expect(locale.region).toEqual('US') + }) + + test('failed', () => { + const mockEvent = { + node: { + req: { + method: 'GET', + headers: { + cookie: 'intlify_locale=f', + }, + }, + }, + } as H3Event + + expect(tryCookieLocale(mockEvent, { name: 'intlify_locale' })).toBeNull() + }) +}) + describe('setCookieLocale', () => { let app: App let request: SuperTest @@ -469,6 +575,36 @@ test('getPathLocale', async () => { expect(res.body).toEqual({ locale: 'en' }) }) +describe('tryPathLocale', () => { + test('success', async () => { + const app = createApp({ debug: false }) + const request = supertest(toNodeListener(app)) + + app.use( + '/', + eventHandler((event) => { + return { locale: tryPathLocale(event)!.toString() } + }), + ) + const res = await request.get('/en/foo') + expect(res.body).toEqual({ locale: 'en' }) + }) + + test('failed', async () => { + const app = createApp({ debug: false }) + const request = supertest(toNodeListener(app)) + + app.use( + '/', + eventHandler((event) => { + return { locale: tryPathLocale(event) } + }), + ) + const res = await request.get('/s/foo') + expect(res.body).toEqual({ locale: null }) + }) +}) + test('getQueryLocale', async () => { const app = createApp({ debug: false }) const request = supertest(toNodeListener(app)) @@ -482,3 +618,33 @@ test('getQueryLocale', async () => { const res = await request.get('/?locale=ja') expect(res.body).toEqual({ locale: 'ja' }) }) + +describe('tryQueryLocale', () => { + test('success', async () => { + const app = createApp({ debug: false }) + const request = supertest(toNodeListener(app)) + + app.use( + '/', + eventHandler((event) => { + return { locale: tryQueryLocale(event)!.toString() } + }), + ) + const res = await request.get('/?locale=ja') + expect(res.body).toEqual({ locale: 'ja' }) + }) + + test('failed', async () => { + const app = createApp({ debug: false }) + const request = supertest(toNodeListener(app)) + + app.use( + '/', + eventHandler((event) => { + return { locale: tryQueryLocale(event) } + }), + ) + const res = await request.get('/?locale=j') + expect(res.body).toEqual({ locale: null }) + }) +}) diff --git a/src/h3.ts b/src/h3.ts index e3c3fea..2a1eb41 100644 --- a/src/h3.ts +++ b/src/h3.ts @@ -107,6 +107,8 @@ export function getHeaderLanguage(event: H3Event, { * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. * + * @throws {RangeError} Throws the {@link RangeError} if header are not a well-formed BCP 47 language tag. + * * @returns {Array} The locales that wrapped from header, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. */ export function getHeaderLocales( @@ -119,6 +121,31 @@ export function getHeaderLocales( return mapToLocaleFromLanguageTag(getHeaderLanguages, event, { name, parser }) } +/** + * try to get locales from header + * + * @description wrap language tags with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. Unlike {@link getHeaderLocales}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {H3Event} event The {@link H3Event | H3} event + * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. + * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. + * + * @returns {Array | null} The locales that wrapped from header, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. if header are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryHeaderLocales( + event: H3Event, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): Intl.Locale[] | null { + try { + return getHeaderLocales(event, { name, parser }) + } catch { + return null + } +} + /** * get locale from header * @@ -158,6 +185,33 @@ export function getHeaderLocale( return getLocaleWithGetter(() => getHeaderLanguages(event, { name, parser })[0] || lang) } +/** + * try to get locale from header + * + * @description wrap language tag with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. Unlike {@link getHeaderLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {H3Event} event The {@link H3Event | H3} event + * @param {string} options.lang The default language tag, Optional. default value is `en-US`. You must specify the language tag with the {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 syntax}. + * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. + * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. + * + * @returns {Intl.Locale | null} The first locale that resolved from header string. if you use `accept-language` header and `*` (any language) or empty string is detected, return `en-US`. if header are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryHeaderLocale( + event: H3Event, + { + lang = DEFAULT_LANG_TAG, + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions & { lang?: string } = {}, +): Intl.Locale | null { + try { + return getHeaderLocale(event, { lang, name, parser }) + } catch { + return null + } +} + /** * get locale from cookie * @@ -190,6 +244,28 @@ export function getCookieLocale( return getLocaleWithGetter(() => getCookie(event, name) || lang) } +/** + * try to get locale from cookie + * + * @description Unlike {@link getCookieLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {H3Event} event The {@link H3Event | H3} event + * @param {string} options.lang The default language tag, default is `en-US`. You must specify the language tag with the {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 syntax}. + * @param {string} options.name The cookie name, default is `i18n_locale` + * + * @returns {Intl.Locale | null} The locale that resolved from cookie. if `lang` option or cookie name value are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryCookieLocale( + event: H3Event, + { lang = DEFAULT_LANG_TAG, name = DEFAULT_COOKIE_NAME } = {}, +): Intl.Locale | null { + try { + return getCookieLocale(event, { lang, name }) + } catch { + return null + } +} + /** * set locale to the response `Set-Cookie` header. * @@ -239,6 +315,28 @@ export function getPathLocale( return _getPathLocale(getRequestURL(event), { lang, parser }) } +/** + * try to get the locale from the path + * + * @description Unlike {@link getPathLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {H3Event} event the {@link H3Event | H3} event + * @param {PathOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {PathOptions['parser']} options.parser the path language parser, default {@link pathLanguageParser}, optional + * + * @returns {Intl.Locale | null} The locale that resolved from path. if the language in the path, that is not a well-formed BCP 47 language tag, return `null`. + */ +export function tryPathLocale( + event: H3Event, + { lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {}, +): Intl.Locale | null { + try { + return getPathLocale(event, { lang, parser }) + } catch { + return null + } +} + /** * get the locale from the query * @@ -256,3 +354,25 @@ export function getQueryLocale( ): Intl.Locale { return _getQueryLocale(getRequestURL(event), { lang, name }) } + +/** + * try to get the locale from the query + * + * @description Unlike {@link getQueryLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {H3Event} event the {@link H3Event | H3} event + * @param {QueryOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {QueryOptions['name']} options.name the query param name, default `'locale'`. optional + * + * @returns {Intl.Locale | null} The locale that resolved from query. if the language in the query, that is not a well-formed BCP 47 language tag, return `null`. + */ +export function tryQueryLocale( + event: H3Event, + { lang = DEFAULT_LANG_TAG, name = 'locale' }: QueryOptions = {}, +): Intl.Locale | null { + try { + return getQueryLocale(event, { lang, name }) + } catch { + return null + } +} diff --git a/src/hono.test.ts b/src/hono.test.ts index c1cbe08..3d43569 100644 --- a/src/hono.test.ts +++ b/src/hono.test.ts @@ -10,6 +10,11 @@ import { getPathLocale, getQueryLocale, setCookieLocale, + tryCookieLocale, + tryHeaderLocale, + tryHeaderLocales, + tryPathLocale, + tryQueryLocale, } from './hono.ts' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' import { Hono } from 'hono' @@ -156,6 +161,27 @@ describe('getHeaderLocales', () => { }) }) +describe('tryHeaderLocales', () => { + test('success', () => { + const mockContext = { + req: { + header: (_name) => 'en-US,en;q=0.9,ja;q=0.8', + }, + } as Context + expect(tryHeaderLocales(mockContext)!.map((locale) => locale.baseName)) + .toEqual(['en-US', 'en', 'ja']) + }) + + test('failed', () => { + const mockContext = { + req: { + header: (_name) => 'hoge', + }, + } as Context + expect(tryHeaderLocales(mockContext)).toBeNull() + }) +}) + describe('getHeaderLocale', () => { test('basic', () => { const mockContext = { @@ -219,6 +245,31 @@ describe('getHeaderLocale', () => { }) }) +describe('tryHeaderLocale', () => { + test('success', () => { + const mockContext = { + req: { + header: (_name) => 'en-US,en;q=0.9,ja;q=0.8', + }, + } as Context + const locale = tryHeaderLocale(mockContext)! + + expect(locale.baseName).toEqual('en-US') + expect(locale.language).toEqual('en') + expect(locale.region).toEqual('US') + }) + + test('failed', () => { + const mockContext = { + req: { + header: (_name) => 'x', + }, + } as Context + + expect(tryHeaderLocale(mockContext)).toBeNull() + }) +}) + describe('getCookieLocale', () => { test('basic', () => { const mockContext = { @@ -298,6 +349,39 @@ describe('getCookieLocale', () => { }) }) +describe('tryCookieLocale', () => { + test('success', () => { + const mockContext = { + req: { + raw: { + headers: { + get: (_name) => `${DEFAULT_COOKIE_NAME}=en-US`, + }, + }, + }, + } as Context + const locale = tryCookieLocale(mockContext)! + + expect(locale.baseName).toEqual('en-US') + expect(locale.language).toEqual('en') + expect(locale.region).toEqual('US') + }) + + test('failed', () => { + const mockContext = { + req: { + raw: { + headers: { + get: (_name) => 'intlify_locale=f', + }, + }, + }, + } as Context + + expect(tryCookieLocale(mockContext, { name: 'intlify_locale' })).toBeNull() + }) +}) + describe('setCookieLocale', () => { test('specify Locale instance', async () => { const app = new Hono() @@ -362,6 +446,28 @@ test('getPathLocale', async () => { expect(result).toEqual({ locale: 'en' }) }) +describe('tryPathLocale', () => { + test('success', async () => { + const app = new Hono() + app.get('*', (c) => { + return c.json({ locale: tryPathLocale(c)!.toString() }) + }) + const res = await app.request('http://localhost/en/foo') + const result = await res.json() + expect(result).toEqual({ locale: 'en' }) + }) + + test('failed', async () => { + const app = new Hono() + app.get('*', (c) => { + return c.json({ locale: tryPathLocale(c) }) + }) + const res = await app.request('http://localhost/e/foo') + const result = await res.json() + expect(result).toEqual({ locale: null }) + }) +}) + test('getQueryLocale', async () => { const app = new Hono() app.get('/', (c) => { @@ -371,3 +477,25 @@ test('getQueryLocale', async () => { const result = await res.json() expect(result).toEqual({ locale: 'ja' }) }) + +describe('tryQueryLocale', () => { + test('success', async () => { + const app = new Hono() + app.get('/', (c) => { + return c.json({ locale: tryQueryLocale(c)!.toString() }) + }) + const res = await app.request('http://localhost/?locale=ja') + const result = await res.json() + expect(result).toEqual({ locale: 'ja' }) + }) + + test('failed', async () => { + const app = new Hono() + app.get('/', (c) => { + return c.json({ locale: tryQueryLocale(c) }) + }) + const res = await app.request('http://localhost/?locale=s') + const result = await res.json() + expect(result).toEqual({ locale: null }) + }) +}) diff --git a/src/hono.ts b/src/hono.ts index 35eca33..82ac1a6 100644 --- a/src/hono.ts +++ b/src/hono.ts @@ -109,6 +109,8 @@ export function getHeaderLanguage(context: Context, { * @param {HeaderOptions['name']} options.name A header name, which is as default `accept-language`. * @param {HeaderOptions['parser']} options.parser A parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. * + * @throws {RangeError} Throws the {@link RangeError} if header are not a well-formed BCP 47 language tag. + * * @returns {Array} Some locales that wrapped from header, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. */ export function getHeaderLocales( @@ -124,6 +126,31 @@ export function getHeaderLocales( }) } +/** + * try to get locales from header + * + * @description wrap language tags with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. Unlike {@link getHeaderLocales}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Context} context A {@link Context | Hono} context + * @param {HeaderOptions['name']} options.name A header name, which is as default `accept-language`. + * @param {HeaderOptions['parser']} options.parser A parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. + * + * @returns {Array | null} Some locales that wrapped from header, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. if header are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryHeaderLocales( + context: Context, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): Intl.Locale[] | null { + try { + return getHeaderLocales(context, { name, parser }) + } catch { + return null + } +} + /** * get locale from header * @@ -164,6 +191,33 @@ export function getHeaderLocale( return getLocaleWithGetter(() => getHeaderLanguages(context, { name, parser })[0] || lang) } +/** + * try to get locale from header + * + * @description wrap language tag with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. Unlike {@link getHeaderLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Context} context A {@link Context | Hono} context + * @param {string} options.lang A default language tag, Optional. default value is `en-US`. You must specify the language tag with the {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 syntax}. + * @param {HeaderOptions['name']} options.name A header name, which is as default `accept-language`. + * @param {HeaderOptions['parser']} options.parser A parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. + * + * @returns {Intl.Locale | null} A first locale that resolved from header string. if you use `accept-language` header and `*` (any language) or empty string is detected, return `en-US`. if `lang` option or header are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryHeaderLocale( + context: Context, + { + lang = DEFAULT_LANG_TAG, + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions & { lang?: string } = {}, +): Intl.Locale | null { + try { + return getHeaderLocale(context, { lang, name, parser }) + } catch { + return null + } +} + /** * get locale from cookie * @@ -197,6 +251,28 @@ export function getCookieLocale( return getLocaleWithGetter(() => getCookie(context, name) || lang) } +/** + * try to get locale from cookie + * + * @description Unlike {@link getCookieLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Context} context A {@link Context | Hono} context + * @param {string} options.lang A default language tag, default is `en-US`. You must specify the language tag with the {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 syntax}. + * @param {string} options.name A cookie name, default is `i18n_locale` + * + * @returns {Intl.Locale | null} The locale that resolved from cookie, if `lang` option or cookie name value are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryCookieLocale( + context: Context, + { lang = DEFAULT_LANG_TAG, name = DEFAULT_COOKIE_NAME } = {}, +): Intl.Locale | null { + try { + return getCookieLocale(context, { lang, name }) + } catch { + return null + } +} + /** * set locale to the response `Set-Cookie` header. * @@ -247,6 +323,28 @@ export function getPathLocale( return _getPathLocale(new URL(context.req.url), { lang, parser }) } +/** + * try to get the locale from the path + * + * @description Unlike {@link getPathLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Context} context A {@link Context | Hono} context + * @param {PathOptions['lang']} options.lang A language tag, which is as default `'en-US'`. optional + * @param {PathOptions['parser']} options.parser the path language parser, default {@link pathLanguageParser}, optional + * + * @returns {Intl.Locale | null} The locale that resolved from path. if the language in the path, that is not a well-formed BCP 47 language tag, return `null`. + */ +export function tryPathLocale( + context: Context, + { lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {}, +): Intl.Locale | null { + try { + return getPathLocale(context, { lang, parser }) + } catch { + return null + } +} + /** * get the locale from the query * @@ -264,3 +362,25 @@ export function getQueryLocale( ): Intl.Locale { return _getQueryLocale(new URL(context.req.url), { lang, name }) } + +/** + * try to get the locale from the query + * + * @description Unlike {@link getQueryLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Context} context A {@link Context | Hono} context + * @param {QueryOptions['lang']} options.lang A language tag, which is as default `'en-US'`. optional + * @param {QueryOptions['name']} options.name A query param name, default `'locale'`. optional + * + * @returns {Intl.Locale | null} The locale that resolved from query. if the language in the query, that is not a well-formed BCP 47 language tag, return `null`. + */ +export function tryQueryLocale( + context: Context, + { lang = DEFAULT_LANG_TAG, name = 'locale' }: QueryOptions = {}, +): Intl.Locale | null { + try { + return getQueryLocale(context, { lang, name }) + } catch { + return null + } +} diff --git a/src/node.test.ts b/src/node.test.ts index f5abf67..9bc87d2 100644 --- a/src/node.test.ts +++ b/src/node.test.ts @@ -9,6 +9,11 @@ import { getPathLocale, getQueryLocale, setCookieLocale, + tryCookieLocale, + tryHeaderLocale, + tryHeaderLocales, + tryPathLocale, + tryQueryLocale, } from './node.ts' import { createServer, IncomingMessage, OutgoingMessage } from 'node:http' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' @@ -138,6 +143,27 @@ describe('getHeaderLocales', () => { }) }) +describe('tryHeaderLocales', () => { + test('success', () => { + const mockRequest = { + headers: { + 'accept-language': 'en-US,en;q=0.9,ja;q=0.8', + }, + } as IncomingMessage + expect(tryHeaderLocales(mockRequest)!.map((locale) => locale.baseName)) + .toEqual(['en-US', 'en', 'ja']) + }) + + test('failed', () => { + const mockRequest = { + headers: { + 'accept-language': 'hoge', + }, + } as IncomingMessage + expect(tryHeaderLocales(mockRequest)).toBeNull() + }) +}) + describe('getHeaderLocale', () => { test('basic', () => { const mockRequest = { @@ -186,6 +212,30 @@ describe('getHeaderLocale', () => { }) }) +describe('tryHeaderLocale', () => { + test('success', () => { + const mockRequest = { + headers: { + 'accept-language': 'en-US,en;q=0.9,ja;q=0.8', + }, + } as IncomingMessage + const locale = tryHeaderLocale(mockRequest)! + + expect(locale.baseName).toEqual('en-US') + expect(locale.language).toEqual('en') + expect(locale.region).toEqual('US') + }) + + test('failed', () => { + const mockRequest = { + headers: { + 'accept-language': 's', + }, + } as IncomingMessage + expect(tryHeaderLocale(mockRequest)).toBeNull() + }) +}) + describe('getCookieLocale', () => { test('basic', () => { const mockRequest = { @@ -258,6 +308,31 @@ describe('getCookieLocale', () => { }) }) +describe('tryCookieLocale', () => { + test('success', () => { + const mockRequest = { + headers: { + cookie: `${DEFAULT_COOKIE_NAME}=en-US`, + }, + } as IncomingMessage + const locale = tryCookieLocale(mockRequest)! + + expect(locale.baseName).toEqual('en-US') + expect(locale.language).toEqual('en') + expect(locale.region).toEqual('US') + }) + + test('failed', () => { + const mockRequest = { + headers: { + cookie: 'intlify_locale=f', + }, + } as IncomingMessage + + expect(tryCookieLocale(mockRequest, { name: 'intlify_locale' })).toBeNull() + }) +}) + describe('setCookieLocale', () => { test('specify Locale instance', async () => { const server = createServer((_req, res) => { @@ -318,6 +393,30 @@ test('getPathLocale', async () => { expect(result.body).toEqual({ locale: 'en-US' }) }) +describe('tryPathLocale', () => { + test('success', async () => { + const server = createServer((req, res) => { + const locale = tryPathLocale(req)! + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ locale: locale.toString() })) + }) + const request = supertest(server) + const result = await request.get('/en-US/foo') + expect(result.body).toEqual({ locale: 'en-US' }) + }) + + test('failed', async () => { + const server = createServer((req, res) => { + const locale = tryPathLocale(req) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ locale })) + }) + const request = supertest(server) + const result = await request.get('/s/foo') + expect(result.body).toEqual({ locale: null }) + }) +}) + test('getQueryLocale', async () => { const server = createServer((req, res) => { const locale = getQueryLocale(req, { name: 'lang' }) @@ -328,3 +427,27 @@ test('getQueryLocale', async () => { const result = await request.get('/?lang=ja') expect(result.body).toEqual({ locale: 'ja' }) }) + +describe('tryQueryLocale', () => { + test('success', async () => { + const server = createServer((req, res) => { + const locale = tryQueryLocale(req, { name: 'lang' })! + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ locale: locale.toString() })) + }) + const request = supertest(server) + const result = await request.get('/?lang=ja') + expect(result.body).toEqual({ locale: 'ja' }) + }) + + test('failed', async () => { + const server = createServer((req, res) => { + const locale = tryQueryLocale(req, { name: 'lang' }) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ locale })) + }) + const request = supertest(server) + const result = await request.get('/?lang=j') + expect(result.body).toEqual({ locale: null }) + }) +}) diff --git a/src/node.ts b/src/node.ts index 1cd903e..901850c 100644 --- a/src/node.ts +++ b/src/node.ts @@ -109,6 +109,10 @@ export function getHeaderLanguage( * ``` * * @param {IncomingMessage} request The {@link IncomingMessage | request} + * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. + * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. + * + * @throws {RangeError} Throws the {@link RangeError} if header are not a well-formed BCP 47 language tag. * * @returns {Array} The locales that wrapped from header, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. */ @@ -125,6 +129,31 @@ export function getHeaderLocales( }) } +/** + * try to get locales from header + * + * @description wrap language tags with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. Unlike {@link getHeaderLocales}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {IncomingMessage} request The {@link IncomingMessage | request} + * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. + * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. + * + * @returns {Array | null} The locales that wrapped from header, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. if header are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryHeaderLocales( + request: IncomingMessage, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): Intl.Locale[] | null { + try { + return getHeaderLocales(request, { name, parser }) + } catch { + return null + } +} + /** * get locale from header * @@ -165,6 +194,33 @@ export function getHeaderLocale( return getLocaleWithGetter(() => getHeaderLanguages(request, { name, parser })[0] || lang) } +/** + * try to get locale from header + * + * @description wrap language tag with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. Unlike {@link getHeaderLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {IncomingMessage} request The {@link IncomingMessage | request} + * @param {string} options.lang The default language tag, Optional. default value is `en-US`. You must specify the language tag with the {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 syntax}. + * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. + * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. + * + * @returns {Intl.Locale | null} The first locale that resolved from header string. if you use `accept-language` header and `*` (any language) or empty string is detected, return `en-US`. if `lang` option or header are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryHeaderLocale( + request: IncomingMessage, + { + lang = DEFAULT_LANG_TAG, + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions & { lang?: string } = {}, +): Intl.Locale | null { + try { + return getHeaderLocale(request, { lang, name, parser }) + } catch { + return null + } +} + /** * get locale from cookie * @@ -202,6 +258,28 @@ export function getCookieLocale( return getLocaleWithGetter(getter) } +/** + * try to get locale from cookie + * + * @description Unlike {@link getCookieLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {IncomingMessage} request The {@link IncomingMessage | request} + * @param {string} options.lang The default language tag, default is `en-US`. You must specify the language tag with the {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 syntax}. + * @param {string} options.name The cookie name, default is `i18n_locale` + * + * @returns {Intl.Locale | null} The locale that resolved from cookie. if `lang` option or cookie name value are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryCookieLocale( + request: IncomingMessage, + { lang = DEFAULT_LANG_TAG, name = DEFAULT_COOKIE_NAME } = {}, +): Intl.Locale | null { + try { + return getCookieLocale(request, { lang, name }) + } catch { + return null + } +} + /** * set locale to the response `Set-Cookie` header. * @@ -302,6 +380,28 @@ export function getPathLocale( return _getPathLocale(getURL(request), { lang, parser }) } +/** + * try to get the locale from the path + * + * @description Unlike {@link getPathLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {IncomingMessage} request the {@link IncomingMessage | request} + * @param {PathOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {PathOptions['parser']} options.parser the path language parser, default {@link pathLanguageParser}, optional + * + * @returns {Intl.Locale | null} The locale that resolved from path. if the language in the path, that is not a well-formed BCP 47 language tag, return `null`. + */ +export function tryPathLocale( + request: IncomingMessage, + { lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {}, +): Intl.Locale | null { + try { + return getPathLocale(request, { lang, parser }) + } catch { + return null + } +} + /** * get the locale from the query * @@ -320,6 +420,28 @@ export function getQueryLocale( return _getQueryLocale(getURL(request), { lang, name }) } +/** + * try to get the locale from the query + * + * @description Unlike {@link getQueryLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {IncomingMessage} request the {@link IncomingMessage | request} + * @param {QueryOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {QueryOptions['name']} options.name the query param name, default `'locale'`. optional + * + * @returns {Intl.Locale | null} The locale that resolved from query. if the language in the query, that is not a well-formed BCP 47 language tag, return `null`. + */ +export function tryQueryLocale( + request: IncomingMessage, + { lang = DEFAULT_LANG_TAG, name = 'locale' }: QueryOptions = {}, +): Intl.Locale | null { + try { + return getQueryLocale(request, { lang, name }) + } catch { + return null + } +} + let navigatorLanguages: string[] | undefined /** diff --git a/src/web.test.ts b/src/web.test.ts index 36a605f..6fb4ae0 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -10,6 +10,11 @@ import { getPathLocale, getQueryLocale, setCookieLocale, + tryCookieLocale, + tryHeaderLocale, + tryHeaderLocales, + tryPathLocale, + tryQueryLocale, } from './web.ts' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' @@ -72,6 +77,21 @@ describe('getHeaderLocales', () => { }) }) +describe('tryHeaderLocales', () => { + test('success', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('accept-language', 'en-US,en;q=0.9,ja;q=0.8') + expect(tryHeaderLocales(mockRequest)!.map((locale) => locale.baseName)) + .toEqual(['en-US', 'en', 'ja']) + }) + + test('failed', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('accept-language', 'hoge') + expect(tryHeaderLocales(mockRequest)).toBeNull() + }) +}) + describe('getAcceptLanguage', () => { test('basic', () => { const mockRequest = new Request('https://example.com') @@ -149,6 +169,24 @@ describe('getHeaderLocale', () => { }) }) +describe('tryHeaderLocale', () => { + test('success', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('accept-language', 'en-US,en;q=0.9,ja;q=0.8') + const locale = tryHeaderLocale(mockRequest)! + + expect(locale.baseName).toEqual('en-US') + expect(locale.language).toEqual('en') + expect(locale.region).toEqual('US') + }) + + test('failed', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('accept-language', 's') + expect(tryHeaderLocale(mockRequest, { lang: 'ja-JP' })).toBeNull() + }) +}) + describe('getCookieLocale', () => { test('basic', () => { const mockRequest = new Request('https://example.com') @@ -191,6 +229,24 @@ describe('getCookieLocale', () => { }) }) +describe('tryCookieLocale', () => { + test('success', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('cookie', `${DEFAULT_COOKIE_NAME}=en-US`) + const locale = tryCookieLocale(mockRequest)! + + expect(locale.baseName).toEqual('en-US') + expect(locale.language).toEqual('en') + expect(locale.region).toEqual('US') + }) + + test('failed', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('cookie', 'intlify_locale=f') + expect(tryCookieLocale(mockRequest, { name: 'intlify_locale' })).toBeNull() + }) +}) + describe('setCookieLocale', () => { test('specify Locale instance', () => { const res = new Response('hello world!') @@ -230,12 +286,40 @@ test('getPathLocale', () => { expect(locale.toString()).toEqual('en') }) +describe('tryPathLocale', () => { + test('success', () => { + const mockRequest = new Request('https://locahost:3000/en/foo') + const locale = tryPathLocale(mockRequest)! + expect(locale.toString()).toEqual('en') + }) + + test('failed', () => { + const mockRequest = new Request('https://locahost:3000/e/foo') + const locale = tryPathLocale(mockRequest) + expect(locale).toBeNull() + }) +}) + test('getQueryLocale', () => { const mockRequest = new Request('https://locahost:3000/?intlify=ja') const locale = getQueryLocale(mockRequest, { name: 'intlify' }) expect(locale.toString()).toEqual('ja') }) +describe('tryQueryLocale', () => { + test('success', () => { + const mockRequest = new Request('https://locahost:3000/?intlify=ja') + const locale = tryQueryLocale(mockRequest, { name: 'intlify' })! + expect(locale.toString()).toEqual('ja') + }) + + test('failed', () => { + const mockRequest = new Request('https://locahost:3000/?intlify=j') + const locale = tryQueryLocale(mockRequest, { name: 'intlify' }) + expect(locale).toBeNull() + }) +}) + describe('getNavigatorLocales', () => { test('basic', () => { vi.stubGlobal('navigator', { diff --git a/src/web.ts b/src/web.ts index bc3f445..4291fe0 100644 --- a/src/web.ts +++ b/src/web.ts @@ -111,6 +111,8 @@ export function getHeaderLanguage( * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. * + * @throws {RangeError} Throws the {@link RangeError} if header are not a well-formed BCP 47 language tag. + * * @returns {Array} The locales that wrapped from header, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. */ export function getHeaderLocales( @@ -126,6 +128,31 @@ export function getHeaderLocales( }) } +/** + * try to get locales from header + * + * @description wrap language tags with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. Unlike {@link getHeaderLocales}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Request} request The {@link Request | request} + * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. + * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. + * + * @returns {Array | null} The locales that wrapped from header, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. if header are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryHeaderLocales( + request: Request, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): Intl.Locale[] | null { + try { + return getHeaderLocales(request, { name, parser }) + } catch { + return null + } +} + /** * get locale from header * @@ -165,6 +192,33 @@ export function getHeaderLocale( return getLocaleWithGetter(() => getHeaderLanguages(request, { name, parser })[0] || lang) } +/** + * try to get locale from header + * + * @description wrap language tag with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. Unlike {@link getHeaderLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Request} request The {@link Request | request} + * @param {string} options.lang The default language tag, Optional. default value is `en-US`. You must specify the language tag with the {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 syntax}. + * @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`. + * @param {HeaderOptions['parser']} options.parser The parser function, which is as default {@link parseDefaultHeader}. If you are specifying more than one in your own format, you need a parser. + * + * @returns {Intl.Locale | null} The first locale that resolved from header string. if you use `accept-language` header and `*` (any language) or empty string is detected, return `en-US`. if `lang` option or header are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryHeaderLocale( + request: Request, + { + lang = DEFAULT_LANG_TAG, + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions & { lang?: string } = {}, +): Intl.Locale | null { + try { + return getHeaderLocale(request, { lang, name, parser }) + } catch { + return null + } +} + /** * get locale from cookie * @@ -203,6 +257,28 @@ export function getCookieLocale( return getLocaleWithGetter(getter) } +/** + * try to get locale from cookie + * + * @description Unlike {@link getCookieLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Request} request The {@link Request | request} + * @param {string} options.lang The default language tag, default is `en-US`. You must specify the language tag with the {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 syntax}. + * @param {string} options.name The cookie name, default is `i18n_locale` + * + * @returns {Intl.Locale | null} The locale that resolved from cookie. if `lang` option or cookie name value are not a well-formed BCP 47 language tag, return `null`. + */ +export function tryCookieLocale( + request: Request, + { lang = DEFAULT_LANG_TAG, name = DEFAULT_COOKIE_NAME } = {}, +): Intl.Locale | null { + try { + return getCookieLocale(request, { lang, name }) + } catch { + return null + } +} + /** * set locale to the response `Set-Cookie` header. * @@ -264,6 +340,28 @@ export function getPathLocale( return _getPathLocale(new URL(request.url), { lang, parser }) } +/** + * try to get the locale from the path + * + * @description Unlike {@link getPathLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Request} request the {@link Request | request} + * @param {PathOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {PathOptions['parser']} options.parser the path language parser, default {@link pathLanguageParser}, optional + * + * @returns {Intl.Locale | null} The locale that resolved from path. if the language in the path, that is not a well-formed BCP 47 language tag, return `null`. + */ +export function tryPathLocale( + request: Request, + { lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {}, +): Intl.Locale | null { + try { + return getPathLocale(request, { lang, parser }) + } catch { + return null + } +} + /** * get the locale from the query * @@ -282,6 +380,28 @@ export function getQueryLocale( return _getQueryLocale(new URL(request.url), { lang, name }) } +/** + * try to get the locale from the query + * + * @description Unlike {@link getQueryLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`. + * + * @param {Request} request the {@link Request | request} + * @param {QueryOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {QueryOptions['name']} options.name the query param name, default `'locale'`. optional + * + * @returns {Intl.Locale | null} The locale that resolved from query. if the language in the query, that is not a well-formed BCP 47 language tag, return `null`. + */ +export function tryQueryLocale( + request: Request, + { lang = DEFAULT_LANG_TAG, name = 'locale' }: QueryOptions = {}, +): Intl.Locale | null { + try { + return getQueryLocale(request, { lang, name }) + } catch { + return null + } +} + /** * get navigator languages *