Skip to content

Commit 7f33b18

Browse files
authored
feat: support try locale getting APIs (#38)
1 parent 1ec90a5 commit 7f33b18

File tree

11 files changed

+1108
-0
lines changed

11 files changed

+1108
-0
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ You can do `import { ... } from '@intlify/utils'` the above utilities
146146
- `setCookieLocale`
147147
- `getPathLocale`
148148
- `getQueryLocale`
149+
- `tryHeaderLocales`
150+
- `tryHeaderLocale`
151+
- `tryCookieLocale`
152+
- `tryPathLocale`
153+
- `tryQueryLocale`
149154

150155
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)
151156

bun.lockb

0 Bytes
Binary file not shown.

deno/web.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export function getHeaderLanguage(
111111
* @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`.
112112
* @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.
113113
*
114+
* @throws {RangeError} Throws the {@link RangeError} if header are not a well-formed BCP 47 language tag.
115+
*
114116
* @returns {Array<Intl.Locale>} The locales that wrapped from header, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array.
115117
*/
116118
export function getHeaderLocales(
@@ -126,6 +128,31 @@ export function getHeaderLocales(
126128
})
127129
}
128130

131+
/**
132+
* try to get locales from header
133+
*
134+
* @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`.
135+
*
136+
* @param {Request} request The {@link Request | request}
137+
* @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`.
138+
* @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.
139+
*
140+
* @returns {Array<Intl.Locale> | 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`.
141+
*/
142+
export function tryHeaderLocales(
143+
request: Request,
144+
{
145+
name = ACCEPT_LANGUAGE_HEADER,
146+
parser = parseDefaultHeader,
147+
}: HeaderOptions = {},
148+
): Intl.Locale[] | null {
149+
try {
150+
return getHeaderLocales(request, { name, parser })
151+
} catch {
152+
return null
153+
}
154+
}
155+
129156
/**
130157
* get locale from header
131158
*
@@ -165,6 +192,33 @@ export function getHeaderLocale(
165192
return getLocaleWithGetter(() => getHeaderLanguages(request, { name, parser })[0] || lang)
166193
}
167194

195+
/**
196+
* try to get locale from header
197+
*
198+
* @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`.
199+
*
200+
* @param {Request} request The {@link Request | request}
201+
* @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}.
202+
* @param {HeaderOptions['name']} options.name The header name, which is as default `accept-language`.
203+
* @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.
204+
*
205+
* @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`.
206+
*/
207+
export function tryHeaderLocale(
208+
request: Request,
209+
{
210+
lang = DEFAULT_LANG_TAG,
211+
name = ACCEPT_LANGUAGE_HEADER,
212+
parser = parseDefaultHeader,
213+
}: HeaderOptions & { lang?: string } = {},
214+
): Intl.Locale | null {
215+
try {
216+
return getHeaderLocale(request, { lang, name, parser })
217+
} catch {
218+
return null
219+
}
220+
}
221+
168222
/**
169223
* get locale from cookie
170224
*
@@ -203,6 +257,28 @@ export function getCookieLocale(
203257
return getLocaleWithGetter(getter)
204258
}
205259

260+
/**
261+
* try to get locale from cookie
262+
*
263+
* @description Unlike {@link getCookieLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`.
264+
*
265+
* @param {Request} request The {@link Request | request}
266+
* @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}.
267+
* @param {string} options.name The cookie name, default is `i18n_locale`
268+
*
269+
* @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`.
270+
*/
271+
export function tryCookieLocale(
272+
request: Request,
273+
{ lang = DEFAULT_LANG_TAG, name = DEFAULT_COOKIE_NAME } = {},
274+
): Intl.Locale | null {
275+
try {
276+
return getCookieLocale(request, { lang, name })
277+
} catch {
278+
return null
279+
}
280+
}
281+
206282
/**
207283
* set locale to the response `Set-Cookie` header.
208284
*
@@ -264,6 +340,28 @@ export function getPathLocale(
264340
return _getPathLocale(new URL(request.url), { lang, parser })
265341
}
266342

343+
/**
344+
* try to get the locale from the path
345+
*
346+
* @description Unlike {@link getPathLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`.
347+
*
348+
* @param {Request} request the {@link Request | request}
349+
* @param {PathOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional
350+
* @param {PathOptions['parser']} options.parser the path language parser, default {@link pathLanguageParser}, optional
351+
*
352+
* @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`.
353+
*/
354+
export function tryPathLocale(
355+
request: Request,
356+
{ lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {},
357+
): Intl.Locale | null {
358+
try {
359+
return getPathLocale(request, { lang, parser })
360+
} catch {
361+
return null
362+
}
363+
}
364+
267365
/**
268366
* get the locale from the query
269367
*
@@ -282,6 +380,28 @@ export function getQueryLocale(
282380
return _getQueryLocale(new URL(request.url), { lang, name })
283381
}
284382

383+
/**
384+
* try to get the locale from the query
385+
*
386+
* @description Unlike {@link getQueryLocale}, this function does not throw an error if the locale cannot be obtained, this function returns `null`.
387+
*
388+
* @param {Request} request the {@link Request | request}
389+
* @param {QueryOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional
390+
* @param {QueryOptions['name']} options.name the query param name, default `'locale'`. optional
391+
*
392+
* @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`.
393+
*/
394+
export function tryQueryLocale(
395+
request: Request,
396+
{ lang = DEFAULT_LANG_TAG, name = 'locale' }: QueryOptions = {},
397+
): Intl.Locale | null {
398+
try {
399+
return getQueryLocale(request, { lang, name })
400+
} catch {
401+
return null
402+
}
403+
}
404+
285405
/**
286406
* get navigator languages
287407
*

src/h3.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import {
1010
getPathLocale,
1111
getQueryLocale,
1212
setCookieLocale,
13+
tryCookieLocale,
14+
tryHeaderLocale,
15+
tryHeaderLocales,
16+
tryPathLocale,
17+
tryQueryLocale,
1318
} from './h3.ts'
1419
import { parseAcceptLanguage } from './shared.ts'
1520
import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts'
@@ -219,6 +224,37 @@ describe('getHeaderLocales', () => {
219224
})
220225
})
221226

227+
describe('tryHeaderLocales', () => {
228+
test('success', () => {
229+
const mockEvent = {
230+
node: {
231+
req: {
232+
method: 'GET',
233+
headers: {
234+
'accept-language': 'en-US,en;q=0.9,ja;q=0.8',
235+
},
236+
},
237+
},
238+
} as H3Event
239+
expect(tryHeaderLocales(mockEvent)!.map((locale) => locale.baseName))
240+
.toEqual(['en-US', 'en', 'ja'])
241+
})
242+
243+
test('failed', () => {
244+
const mockEvent = {
245+
node: {
246+
req: {
247+
method: 'GET',
248+
headers: {
249+
'accept-language': 'hoge',
250+
},
251+
},
252+
},
253+
} as H3Event
254+
expect(tryHeaderLocales(mockEvent)).toBeNull()
255+
})
256+
})
257+
222258
describe('getHeaderLocale', () => {
223259
test('basic', () => {
224260
const mockEvent = {
@@ -308,6 +344,41 @@ describe('getHeaderLocale', () => {
308344
})
309345
})
310346

347+
describe('tryHeaderLocale', () => {
348+
test('success', () => {
349+
const mockEvent = {
350+
node: {
351+
req: {
352+
method: 'GET',
353+
headers: {
354+
'accept-language': 'en-US,en;q=0.9,ja;q=0.8',
355+
},
356+
},
357+
},
358+
} as H3Event
359+
const locale = tryHeaderLocale(mockEvent)!
360+
361+
expect(locale.baseName).toEqual('en-US')
362+
expect(locale.language).toEqual('en')
363+
expect(locale.region).toEqual('US')
364+
})
365+
366+
test('failed', () => {
367+
const mockEvent = {
368+
node: {
369+
req: {
370+
method: 'GET',
371+
headers: {
372+
'accept-language': 's',
373+
},
374+
},
375+
},
376+
} as H3Event
377+
378+
expect(tryHeaderLocale(mockEvent)).toBeNull()
379+
})
380+
})
381+
311382
describe('getCookieLocale', () => {
312383
test('basic', () => {
313384
const mockEvent = {
@@ -388,6 +459,41 @@ describe('getCookieLocale', () => {
388459
})
389460
})
390461

462+
describe('tryCookieLocale', () => {
463+
test('success', () => {
464+
const mockEvent = {
465+
node: {
466+
req: {
467+
method: 'GET',
468+
headers: {
469+
cookie: `${DEFAULT_COOKIE_NAME}=en-US`,
470+
},
471+
},
472+
},
473+
} as H3Event
474+
const locale = tryCookieLocale(mockEvent)!
475+
476+
expect(locale.baseName).toEqual('en-US')
477+
expect(locale.language).toEqual('en')
478+
expect(locale.region).toEqual('US')
479+
})
480+
481+
test('failed', () => {
482+
const mockEvent = {
483+
node: {
484+
req: {
485+
method: 'GET',
486+
headers: {
487+
cookie: 'intlify_locale=f',
488+
},
489+
},
490+
},
491+
} as H3Event
492+
493+
expect(tryCookieLocale(mockEvent, { name: 'intlify_locale' })).toBeNull()
494+
})
495+
})
496+
391497
describe('setCookieLocale', () => {
392498
let app: App
393499
let request: SuperTest<Test>
@@ -469,6 +575,36 @@ test('getPathLocale', async () => {
469575
expect(res.body).toEqual({ locale: 'en' })
470576
})
471577

578+
describe('tryPathLocale', () => {
579+
test('success', async () => {
580+
const app = createApp({ debug: false })
581+
const request = supertest(toNodeListener(app))
582+
583+
app.use(
584+
'/',
585+
eventHandler((event) => {
586+
return { locale: tryPathLocale(event)!.toString() }
587+
}),
588+
)
589+
const res = await request.get('/en/foo')
590+
expect(res.body).toEqual({ locale: 'en' })
591+
})
592+
593+
test('failed', async () => {
594+
const app = createApp({ debug: false })
595+
const request = supertest(toNodeListener(app))
596+
597+
app.use(
598+
'/',
599+
eventHandler((event) => {
600+
return { locale: tryPathLocale(event) }
601+
}),
602+
)
603+
const res = await request.get('/s/foo')
604+
expect(res.body).toEqual({ locale: null })
605+
})
606+
})
607+
472608
test('getQueryLocale', async () => {
473609
const app = createApp({ debug: false })
474610
const request = supertest(toNodeListener(app))
@@ -482,3 +618,33 @@ test('getQueryLocale', async () => {
482618
const res = await request.get('/?locale=ja')
483619
expect(res.body).toEqual({ locale: 'ja' })
484620
})
621+
622+
describe('tryQueryLocale', () => {
623+
test('success', async () => {
624+
const app = createApp({ debug: false })
625+
const request = supertest(toNodeListener(app))
626+
627+
app.use(
628+
'/',
629+
eventHandler((event) => {
630+
return { locale: tryQueryLocale(event)!.toString() }
631+
}),
632+
)
633+
const res = await request.get('/?locale=ja')
634+
expect(res.body).toEqual({ locale: 'ja' })
635+
})
636+
637+
test('failed', async () => {
638+
const app = createApp({ debug: false })
639+
const request = supertest(toNodeListener(app))
640+
641+
app.use(
642+
'/',
643+
eventHandler((event) => {
644+
return { locale: tryQueryLocale(event) }
645+
}),
646+
)
647+
const res = await request.get('/?locale=j')
648+
expect(res.body).toEqual({ locale: null })
649+
})
650+
})

0 commit comments

Comments
 (0)