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 3d6adea..68b8833 100755 Binary files a/bun.lockb and b/bun.lockb differ 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 *