From 6c8a28a6ec31935b310b68597930ab7afa259507 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 28 Sep 2023 13:45:10 +0900 Subject: [PATCH] BREAKING CHANGE: Support for other headers, not 'accept-language' (#15) --- README.md | 8 +-- mod.ts | 4 ++ playground/deno/main.ts | 6 +- playground/node/index.ts | 6 +- src/constants.ts | 1 + src/h3.test.ts | 122 ++++++++++++++++++++++++++++++++------- src/h3.ts | 97 ++++++++++++++++++++----------- src/http.ts | 28 +++++++-- src/node.test.ts | 100 +++++++++++++++++++++++++------- src/node.ts | 106 +++++++++++++++++++++++----------- src/web.test.ts | 84 ++++++++++++++++++++------- src/web.ts | 106 +++++++++++++++++++++++----------- 12 files changed, 490 insertions(+), 178 deletions(-) create mode 100644 mod.ts diff --git a/README.md b/README.md index 6d6b789..ccaeac6 100644 --- a/README.md +++ b/README.md @@ -138,10 +138,10 @@ You can do `import { ... } from '@intlify/utils'` the above utilities ### HTTP -- `getAcceptLanguages` -- `getAcceptLanguage` -- `getAcceptLocales` -- `getAcceptLocale` +- `getHeaderLanguages` +- `getHeaderLanguage` +- `getHeaderLocales` +- `getHeaderLocale` - `getCookieLocale` - `setCookieLocale` - `getPathLanguage` diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..881e878 --- /dev/null +++ b/mod.ts @@ -0,0 +1,4 @@ +console.log('main navigator.language', navigator.language) +console.log('main navigator.languages', navigator.languages) + +new Worker(new URL('./worker.ts', import.meta.url).href, { type: 'module' }) diff --git a/playground/deno/main.ts b/playground/deno/main.ts index d5a1f6a..99084f1 100644 --- a/playground/deno/main.ts +++ b/playground/deno/main.ts @@ -1,10 +1,10 @@ -import { getAcceptLanguages } from 'https://esm.sh/@intlify/utils/web' +import { getHeaderLanguages } from 'https://esm.sh/@intlify/utils/web' const port = 8125 Deno.serve({ port, }, (req: Request) => { - const acceptLanguages = getAcceptLanguages(req) - return new Response(`detect accpect-language: ${acceptLanguages}`) + const languages = getHeaderLanguages(req) + return new Response(`detect accpect-language: ${languages}`) }) console.log(`server listening on ${port}`) diff --git a/playground/node/index.ts b/playground/node/index.ts index 2dfde5b..4decb2c 100644 --- a/playground/node/index.ts +++ b/playground/node/index.ts @@ -1,11 +1,11 @@ import { createServer } from 'node:http' -import { getAcceptLanguages } from '@intlify/utils/node' +import { getHeaderLanguages } from '@intlify/utils/node' const server = createServer((req, res) => { - const acceptLanguages = getAcceptLanguages(req) + const languages = getHeaderLanguages(req) res.writeHead(200) - res.end(`detect accpect-language: ${acceptLanguages}`) + res.end(`detect accpect-language: ${languages}`) }) server.listen(8123) diff --git a/src/constants.ts b/src/constants.ts index 29dcbf2..4e817d2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,2 +1,3 @@ export const DEFAULT_LANG_TAG = 'en-US' export const DEFAULT_COOKIE_NAME = 'i18n_locale' +export const ACCEPT_LANGUAGE_HEADER = 'accept-language' diff --git a/src/h3.test.ts b/src/h3.test.ts index f5e80e0..b8ea9b8 100644 --- a/src/h3.test.ts +++ b/src/h3.test.ts @@ -2,11 +2,11 @@ import { beforeEach, describe, expect, test } from 'vitest' import { createApp, eventHandler, toNodeListener } from 'h3' import supertest from 'supertest' import { - getAcceptLanguage, - getAcceptLanguages, - getAcceptLocale, - getAcceptLocales, getCookieLocale, + getHeaderLanguage, + getHeaderLanguages, + getHeaderLocale, + getHeaderLocales, setCookieLocale, } from './h3.ts' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' @@ -14,7 +14,7 @@ import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' import type { App, H3Event } from 'h3' import type { SuperTest, Test } from 'supertest' -describe('getAcceptLanguages', () => { +describe('getHeaderLanguages', () => { test('basic', () => { const mockEvent = { node: { @@ -26,7 +26,7 @@ describe('getAcceptLanguages', () => { }, }, } as H3Event - expect(getAcceptLanguages(mockEvent)).toEqual(['en-US', 'en', 'ja']) + expect(getHeaderLanguages(mockEvent)).toEqual(['en-US', 'en', 'ja']) }) test('any language', () => { @@ -40,7 +40,7 @@ describe('getAcceptLanguages', () => { }, }, } as H3Event - expect(getAcceptLanguages(mockEvent)).toEqual([]) + expect(getHeaderLanguages(mockEvent)).toEqual([]) }) test('empty', () => { @@ -52,7 +52,27 @@ describe('getAcceptLanguages', () => { }, }, } as H3Event - expect(getAcceptLanguages(mockEvent)).toEqual([]) + expect(getHeaderLanguages(mockEvent)).toEqual([]) + }) + + test('custom header', () => { + // @ts-ignore: for mocking + const mockEvent = { + node: { + req: { + method: 'GET', + headers: { + 'x-inlitfy-language': 'en-US,en,ja', + }, + }, + }, + } as H3Event + expect( + getHeaderLanguages(mockEvent, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }), + ).toEqual(['en-US', 'en', 'ja']) }) }) @@ -68,7 +88,7 @@ describe('getAcceptLanguage', () => { }, }, } as H3Event - expect(getAcceptLanguage(mockEvent)).toEqual('en-US') + expect(getHeaderLanguage(mockEvent)).toEqual('en-US') }) test('any language', () => { @@ -82,7 +102,7 @@ describe('getAcceptLanguage', () => { }, }, } as H3Event - expect(getAcceptLanguage(mockEvent)).toEqual('') + expect(getHeaderLanguage(mockEvent)).toEqual('') }) test('empty', () => { @@ -94,11 +114,31 @@ describe('getAcceptLanguage', () => { }, }, } as H3Event - expect(getAcceptLanguage(mockEvent)).toEqual('') + expect(getHeaderLanguage(mockEvent)).toEqual('') + }) + + test('custom header', () => { + // @ts-ignore: for mocking + const mockEvent = { + node: { + req: { + method: 'GET', + headers: { + 'x-inlitfy-language': 'en-US,en,ja', + }, + }, + }, + } as H3Event + expect( + getHeaderLanguage(mockEvent, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }), + ).toEqual('en-US') }) }) -describe('getAcceptLocales', () => { +describe('getHeaderLocales', () => { test('basic', () => { const mockEvent = { node: { @@ -110,7 +150,7 @@ describe('getAcceptLocales', () => { }, }, } as H3Event - expect(getAcceptLocales(mockEvent).map((locale) => locale.baseName)) + expect(getHeaderLocales(mockEvent).map((locale) => locale.baseName)) .toEqual(['en-US', 'en', 'ja']) }) @@ -125,7 +165,7 @@ describe('getAcceptLocales', () => { }, }, } as H3Event - expect(getAcceptLocales(mockEvent)).toEqual([]) + expect(getHeaderLocales(mockEvent)).toEqual([]) }) test('empty', () => { @@ -137,11 +177,31 @@ describe('getAcceptLocales', () => { }, }, } as H3Event - expect(getAcceptLocales(mockEvent)).toEqual([]) + expect(getHeaderLocales(mockEvent)).toEqual([]) + }) + + test('custom header', () => { + // @ts-ignore: for mocking + const mockEvent = { + node: { + req: { + method: 'GET', + headers: { + 'x-inlitfy-language': 'en-US,en,ja', + }, + }, + }, + } as H3Event + expect( + getHeaderLocales(mockEvent, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }).map((locale) => locale.baseName), + ).toEqual(['en-US', 'en', 'ja']) }) }) -describe('getAcceptLocale', () => { +describe('getHeaderLocale', () => { test('basic', () => { const mockEvent = { node: { @@ -153,7 +213,7 @@ describe('getAcceptLocale', () => { }, }, } as H3Event - const locale = getAcceptLocale(mockEvent) + const locale = getHeaderLocale(mockEvent) expect(locale.baseName).toEqual('en-US') expect(locale.language).toEqual('en') @@ -171,7 +231,7 @@ describe('getAcceptLocale', () => { }, }, } as H3Event - const locale = getAcceptLocale(mockEvent) + const locale = getHeaderLocale(mockEvent) expect(locale.baseName).toEqual(DEFAULT_LANG_TAG) }) @@ -187,7 +247,7 @@ describe('getAcceptLocale', () => { }, }, } as H3Event - const locale = getAcceptLocale(mockEvent, 'ja-JP') + const locale = getHeaderLocale(mockEvent, { lang: 'ja-JP' }) expect(locale.baseName).toEqual('ja-JP') }) @@ -204,7 +264,29 @@ describe('getAcceptLocale', () => { }, } as H3Event - expect(() => getAcceptLocale(mockEvent, 'ja-JP')).toThrowError(RangeError) + expect(() => getHeaderLocale(mockEvent, { lang: 'ja-JP' })).toThrowError( + RangeError, + ) + }) + + test('custom header', () => { + // @ts-ignore: for mocking + const mockEvent = { + node: { + req: { + method: 'GET', + headers: { + 'x-inlitfy-language': 'en-US,en,ja', + }, + }, + }, + } as H3Event + expect( + getHeaderLocale(mockEvent, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }).toString(), + ).toEqual('en-US') }) }) diff --git a/src/h3.ts b/src/h3.ts index 4413e43..bb82837 100644 --- a/src/h3.ts +++ b/src/h3.ts @@ -1,51 +1,61 @@ import { - getAcceptLanguagesWithGetter, + ACCEPT_LANGUAGE_HEADER, + DEFAULT_COOKIE_NAME, + DEFAULT_LANG_TAG, +} from './constants.ts' +import { + getHeaderLanguagesWithGetter, getLocaleWithGetter, mapToLocaleFromLanguageTag, + parseDefaultHeader, validateLocale, } from './http.ts' import { getCookie, getHeaders, setCookie } from 'h3' -import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' import type { H3Event } from 'h3' -import type { CookieOptions } from './http.ts' +import type { CookieOptions, HeaderOptions } from './http.ts' /** - * get accpet languages + * get languages from header * - * @description parse `accept-language` header string + * @description parse header string, default `accept-language` header * * @example * example for h3: * * ```ts * import { createApp, eventHandler } from 'h3' - * import { getAcceptLanguages } from '@intlify/utils/h3' + * import { getHeaderLanguages } from '@intlify/utils/h3' * * const app = createApp() * app.use(eventHandler(event) => { - * const langTags = getAcceptLanguages(event) + * const langTags = getHeaderLanguages(event) * // ... * return `accepted languages: ${acceptLanguages.join(', ')}` * }) * ``` * * @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} The array of language tags, if `*` (any language) or empty string is detected, return an empty array. + * @returns {Array} The array of language tags, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. */ -export function getAcceptLanguages(event: H3Event): string[] { +export function getHeaderLanguages(event: H3Event, { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, +}: HeaderOptions = {}): string[] { const getter = () => { const headers = getHeaders(event) - return headers['accept-language'] + return headers[name] } - return getAcceptLanguagesWithGetter(getter) + return getHeaderLanguagesWithGetter(getter, { name, parser }) } /** - * get accept language + * get language from header * - * @description parse `accept-language` header string, this function retuns the **first language tag** of `accept-language` header. + * @description parse header string, default `accept-language`. if you use `accept-language`, this function retuns the **first language tag** of `accept-language` header. * * @example * example for h3: @@ -56,80 +66,99 @@ export function getAcceptLanguages(event: H3Event): string[] { * * const app = createApp() * app.use(eventHandler(event) => { - * const langTag = getAcceptLanguage(event) + * const langTag = getHeaderLanguage(event) * // ... * return `accepted language: ${langTag}` * }) * ``` * * @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 {string} The **first language tag** of `accept-language` header, if `accept-language` header is not exists, or `*` (any language), return empty string. + * @returns {string} The **first language tag** of header, if header is not exists, or `*` (any language), return empty string. */ -export function getAcceptLanguage(event: H3Event): string { - return getAcceptLanguages(event)[0] || '' +export function getHeaderLanguage(event: H3Event, { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, +}: HeaderOptions = {}): string { + return getHeaderLanguages(event, { name, parser })[0] || '' } /** - * get locales from `accept-language` header + * get locales from header * - * @description wrap language tags with {@link Intl.Locale | locale} + * @description wrap language tags with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. * * @example * example for h3: * * ```ts * import { createApp, eventHandler } from 'h3' - * import { getAcceptLocales } from '@intlify/utils/h3' + * import { getHeaderLocales } from '@intlify/utils/h3' * * app.use(eventHandler(event) => { - * const locales = getAcceptLocales(event) + * const locales = getHeaderLocales(event) * // ... * return `accepted locales: ${locales.map(locale => locale.toString()).join(', ')}` * }) * ``` * * @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} The locales that wrapped from `accept-language` header, if `*` (any language) or empty string is detected, return an empty array. + * @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 getAcceptLocales( +export function getHeaderLocales( event: H3Event, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, ): Intl.Locale[] { - return mapToLocaleFromLanguageTag(getAcceptLanguages, event) + return mapToLocaleFromLanguageTag(getHeaderLanguages, event, { name, parser }) } /** - * get locale from `accept-language` header + * get locale from header * - * @description wrap language tag with {@link Intl.Locale | locale} + * @description wrap language tag with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. * * @example * example for h3: * * ```ts * import { createApp, eventHandler } from 'h3' - * import { getAcceptLocale } from '@intlify/utils/h3' + * import { getHeaderLocale } from '@intlify/utils/h3' * * app.use(eventHandler(event) => { - * const locale = getAcceptLocale(event) + * const locale = getHeaderLocale(event) * // ... * return `accepted locale: ${locale.toString()}` * }) * ``` * * @param {H3Event} event The {@link H3Event | H3} event - * @param {string} 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 {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. * - * @throws {RangeError} Throws the {@link RangeError} if `lang` option or `accpet-languages` are not a well-formed BCP 47 language tag. + * @throws {RangeError} Throws the {@link RangeError} if `lang` option or header are not a well-formed BCP 47 language tag. * - * @returns {Intl.Locale} The first locale that resolved from `accept-language` header string, first language tag is used. if `*` (any language) or empty string is detected, return `en-US`. + * @returns {Intl.Locale} 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`. */ -export function getAcceptLocale( +export function getHeaderLocale( event: H3Event, - lang = DEFAULT_LANG_TAG, + { + lang = DEFAULT_LANG_TAG, + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions & { lang?: string } = {}, ): Intl.Locale { - return getLocaleWithGetter(() => getAcceptLanguages(event)[0] || lang) + return getLocaleWithGetter(() => + getHeaderLanguages(event, { name, parser })[0] || lang + ) } /** diff --git a/src/http.ts b/src/http.ts index 088c10e..1395614 100644 --- a/src/http.ts +++ b/src/http.ts @@ -6,6 +6,7 @@ import { pathLanguageParser, validateLanguageTag, } from './shared.ts' +import { ACCEPT_LANGUAGE_HEADER } from './constants.ts' import type { PathLanguageParser } from './shared.ts' // import type { CookieSerializeOptions } from 'cookie-es' @@ -110,11 +111,28 @@ interface CookieSerializeOptions { export type CookieOptions = CookieSerializeOptions & { name?: string } -export function getAcceptLanguagesWithGetter( +export type HeaderOptions = { + name?: string + parser?: typeof parseAcceptLanguage +} + +export function parseDefaultHeader(input: string): string[] { + return [input] +} + +export function getHeaderLanguagesWithGetter( getter: () => string | null | undefined, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, ): string[] { - const acceptLanguage = getter() - return acceptLanguage ? parseAcceptLanguage(acceptLanguage) : [] + const langString = getter() + return langString + ? name === ACCEPT_LANGUAGE_HEADER + ? parseAcceptLanguage(langString) + : parser(langString) + : [] } export function getLocaleWithGetter(getter: () => string): Intl.Locale { @@ -133,9 +151,9 @@ export function validateLocale(locale: string | Intl.Locale): void { export function mapToLocaleFromLanguageTag( // deno-lint-ignore no-explicit-any getter: (...args: any[]) => string[], - arg: unknown, + ...args: unknown[] ): Intl.Locale[] { - return (Reflect.apply(getter, null, [arg]) as string[]).map((lang) => + return (Reflect.apply(getter, null, args) as string[]).map((lang) => getLocaleWithGetter(() => lang) ) } diff --git a/src/node.test.ts b/src/node.test.ts index 769fe0a..6d131fd 100644 --- a/src/node.test.ts +++ b/src/node.test.ts @@ -1,24 +1,24 @@ import { describe, expect, test } from 'vitest' import supertest from 'supertest' import { - getAcceptLanguage, - getAcceptLanguages, - getAcceptLocale, - getAcceptLocales, getCookieLocale, + getHeaderLanguage, + getHeaderLanguages, + getHeaderLocale, + getHeaderLocales, setCookieLocale, } from './node.ts' import { createServer, IncomingMessage, OutgoingMessage } from 'node:http' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' -describe('getAcceptLanguages', () => { +describe('getHeaderLanguages', () => { test('basic', () => { const mockRequest = { headers: { 'accept-language': 'en-US,en;q=0.9,ja;q=0.8', }, } as IncomingMessage - expect(getAcceptLanguages(mockRequest)).toEqual(['en-US', 'en', 'ja']) + expect(getHeaderLanguages(mockRequest)).toEqual(['en-US', 'en', 'ja']) }) test('any language', () => { @@ -27,14 +27,27 @@ describe('getAcceptLanguages', () => { 'accept-language': '*', }, } as IncomingMessage - expect(getAcceptLanguages(mockRequest)).toEqual([]) + expect(getHeaderLanguages(mockRequest)).toEqual([]) }) test('empty', () => { const mockRequest = { headers: {}, } as IncomingMessage - expect(getAcceptLanguages(mockRequest)).toEqual([]) + expect(getHeaderLanguages(mockRequest)).toEqual([]) + }) + + test('custom header', () => { + // @ts-ignore: for mocking + const mockRequest = { + headers: { + 'x-inlitfy-language': 'en-US,en,ja', + }, + } as IncomingMessage + expect(getHeaderLanguages(mockRequest, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + })).toEqual(['en-US', 'en', 'ja']) }) }) @@ -45,7 +58,7 @@ describe('getAcceptLanguage', () => { 'accept-language': 'en-US,en;q=0.9,ja;q=0.8', }, } as IncomingMessage - expect(getAcceptLanguage(mockRequest)).toBe('en-US') + expect(getHeaderLanguage(mockRequest)).toBe('en-US') }) test('any language', () => { @@ -54,25 +67,40 @@ describe('getAcceptLanguage', () => { 'accept-language': '*', }, } as IncomingMessage - expect(getAcceptLanguage(mockRequest)).toBe('') + expect(getHeaderLanguage(mockRequest)).toBe('') }) test('empty', () => { const mockRequest = { headers: {}, } as IncomingMessage - expect(getAcceptLanguage(mockRequest)).toBe('') + expect(getHeaderLanguage(mockRequest)).toBe('') + }) + + test('custom header', () => { + // @ts-ignore: for mocking + const mockRequest = { + headers: { + 'x-inlitfy-language': 'en-US,en,ja', + }, + } as IncomingMessage + expect( + getHeaderLanguage(mockRequest, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }), + ).toEqual('en-US') }) }) -describe('getAcceptLocales', () => { +describe('getHeaderLocales', () => { test('basic', () => { const mockRequest = { headers: { 'accept-language': 'en-US,en;q=0.9,ja;q=0.8', }, } as IncomingMessage - expect(getAcceptLocales(mockRequest).map((locale) => locale.baseName)) + expect(getHeaderLocales(mockRequest).map((locale) => locale.baseName)) .toEqual(['en-US', 'en', 'ja']) }) @@ -82,25 +110,40 @@ describe('getAcceptLocales', () => { 'accept-language': '*', }, } as IncomingMessage - expect(getAcceptLocales(mockRequest)).toEqual([]) + expect(getHeaderLocales(mockRequest)).toEqual([]) }) test('empty', () => { const mockRequest = { headers: {}, } as IncomingMessage - expect(getAcceptLocales(mockRequest)).toEqual([]) + expect(getHeaderLocales(mockRequest)).toEqual([]) + }) + + test('custom header', () => { + // @ts-ignore: for mocking + const mockRequest = { + headers: { + 'x-inlitfy-language': 'en-US,en,ja', + }, + } as IncomingMessage + expect( + getHeaderLocales(mockRequest, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }).map((locale) => locale.baseName), + ).toEqual(['en-US', 'en', 'ja']) }) }) -describe('getAcceptLocale', () => { +describe('getHeaderLocale', () => { test('basic', () => { const mockRequest = { headers: { 'accept-language': 'en-US,en;q=0.9,ja;q=0.8', }, } as IncomingMessage - const locale = getAcceptLocale(mockRequest) + const locale = getHeaderLocale(mockRequest) expect(locale.baseName).toEqual('en-US') expect(locale.language).toEqual('en') @@ -113,7 +156,7 @@ describe('getAcceptLocale', () => { 'accept-language': '*', }, } as IncomingMessage - const locale = getAcceptLocale(mockRequest) + const locale = getHeaderLocale(mockRequest) expect(locale.baseName).toEqual(DEFAULT_LANG_TAG) }) @@ -124,7 +167,7 @@ describe('getAcceptLocale', () => { 'accept-language': '*', }, } as IncomingMessage - const locale = getAcceptLocale(mockRequest, 'ja-JP') + const locale = getHeaderLocale(mockRequest, { lang: 'ja-JP' }) expect(locale.baseName).toEqual('ja-JP') }) @@ -135,7 +178,9 @@ describe('getAcceptLocale', () => { 'accept-language': 's', }, } as IncomingMessage - expect(() => getAcceptLocale(mockRequest, 'ja-JP')).toThrowError(RangeError) + expect(() => getHeaderLocale(mockRequest, { lang: 'ja-JP' })).toThrowError( + RangeError, + ) }) }) @@ -194,6 +239,21 @@ describe('getCookieLocale', () => { expect(() => getCookieLocale(mockRequest, { name: 'intlify_locale' })) .toThrowError(RangeError) }) + + test('custom header', () => { + // @ts-ignore: for mocking + const mockRequest = { + headers: { + 'x-inlitfy-language': 'en-US,en,ja', + }, + } as IncomingMessage + expect( + getHeaderLocale(mockRequest, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }).toString(), + ).toEqual('en-US') + }) }) describe('setCookieLocale', () => { diff --git a/src/node.ts b/src/node.ts index cab3c59..4981ad6 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,31 +1,36 @@ import { IncomingMessage, OutgoingMessage } from 'node:http' import { parse, serialize } from 'cookie-es' import { - getAcceptLanguagesWithGetter, getExistCookies, + getHeaderLanguagesWithGetter, getLocaleWithGetter, mapToLocaleFromLanguageTag, + parseDefaultHeader, validateLocale, } from './http.ts' +import { + ACCEPT_LANGUAGE_HEADER, + DEFAULT_COOKIE_NAME, + DEFAULT_LANG_TAG, +} from './constants.ts' import { normalizeLanguageName } from './shared.ts' -import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' -import type { CookieOptions } from './http.ts' +import type { CookieOptions, HeaderOptions } from './http.ts' /** - * get accpet languages + * get languages from header * - * @description parse `accept-language` header string + * @description parse header string, default `accept-language` header * * @example * example for Node.js request: * * ```ts * import { createServer } from 'node:http' - * import { getAcceptLanguages } from '@intlify/utils/node' + * import { getHeaderLanguages } from '@intlify/utils/node' * * const server = createServer((req, res) => { - * const langTags = getAcceptLanguages(req) + * const langTags = getHeaderLanguages(req) * // ... * res.writeHead(200) * res.end(`detect accpect-languages: ${langTags.join(', ')}`) @@ -33,28 +38,36 @@ import type { CookieOptions } from './http.ts' * ``` * * @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} The array of language tags, if `*` (any language) or empty string is detected, return an empty array. + * @returns {Array} The array of language tags, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. */ -export function getAcceptLanguages(request: IncomingMessage): string[] { - const getter = () => request.headers['accept-language'] - return getAcceptLanguagesWithGetter(getter) +export function getHeaderLanguages( + request: IncomingMessage, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): string[] { + const getter = () => request.headers[name] as string | undefined + return getHeaderLanguagesWithGetter(getter, { name, parser }) } /** - * get accept language + * get language from header * - * @description parse `accept-language` header string, this function retuns the **first language tag** of `accept-language` header. + * @description parse header string, default `accept-language`. if you use `accept-language`, this function retuns the **first language tag** of `accept-language` header. * * @example * example for Node.js request: * * ```ts * import { createServer } from 'node:http' - * import { getAcceptLanguage } from '@intlify/utils/node' + * import { getHeaderLanguage } from '@intlify/utils/node' * * const server = createServer((req, res) => { - * const langTag = getAcceptLanguage(req) + * const langTag = getHeaderLanguage(req) * // ... * res.writeHead(200) * res.end(`detect accpect-language: ${langTag}`) @@ -62,27 +75,35 @@ export function getAcceptLanguages(request: IncomingMessage): string[] { * ``` * * @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 {string} The **first language tag** of `accept-language` header, if `accept-language` header is not exists, or `*` (any language), return empty string. + * @returns {string} The **first language tag** of header, if header is not exists, or `*` (any language), return empty string. */ -export function getAcceptLanguage(request: IncomingMessage): string { - return getAcceptLanguages(request)[0] || '' +export function getHeaderLanguage( + request: IncomingMessage, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): string { + return getHeaderLanguages(request, { name, parser })[0] || '' } /** - * get locales from `accept-language` header + * get locales from header * - * @description wrap language tags with {@link Intl.Locale | locale} + * @description wrap language tags with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. * * @example * example for Node.js request: * * ```ts * import { createServer } from 'node:http' - * import { getAcceptLocales } from '@intlify/utils/node' + * import { getHeaderLocales } from '@intlify/utils/node' * * const server = createServer((req, res) => { - * const locales = getAcceptLocales(req) + * const locales = getHeaderLocales(req) * // ... * res.writeHead(200) * res.end(`accpected locales: ${locales.map(locale => locale.toString()).join(', ')}`) @@ -91,28 +112,35 @@ export function getAcceptLanguage(request: IncomingMessage): string { * * @param {IncomingMessage} request The {@link IncomingMessage | request} * - * @returns {Array} The locales that wrapped from `accept-language` header, if `*` (any language) or empty string is detected, return an empty array. + * @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 getAcceptLocales( +export function getHeaderLocales( request: IncomingMessage, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, ): Intl.Locale[] { - return mapToLocaleFromLanguageTag(getAcceptLanguages, request) + return mapToLocaleFromLanguageTag(getHeaderLanguages, request, { + name, + parser, + }) } /** - * get locale from `accept-language` header + * get locale from header * - * @description wrap language tag with {@link Intl.Locale | locale} + * @description wrap language tag with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. * * @example * example for Node.js request: * * ```ts * import { createServer } from 'node:http' - * import { getAcceptLocale } from '@intlify/utils/node' + * import { getHeaderLocale } from '@intlify/utils/node' * * const server = createServer((req, res) => { - * const locale = getAcceptLocale(req) + * const locale = getHeaderLocale(req) * // ... * res.writeHead(200) * res.end(`accpected locale: ${locale.toString()}`) @@ -120,17 +148,25 @@ export function getAcceptLocales( * ``` * * @param {IncomingMessage} request The {@link IncomingMessage | request} - * @param {string} 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 {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. * - * @throws {RangeError} Throws the {@link RangeError} if `lang` option or `accpet-languages` are not a well-formed BCP 47 language tag. + * @throws {RangeError} Throws the {@link RangeError} if `lang` option or header are not a well-formed BCP 47 language tag. * - * @returns {Intl.Locale} The first locale that resolved from `accept-language` header string, first language tag is used. if `*` (any language) or empty string is detected, return `en-US`. + * @returns {Intl.Locale} 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`. */ -export function getAcceptLocale( +export function getHeaderLocale( request: IncomingMessage, - lang = DEFAULT_LANG_TAG, + { + lang = DEFAULT_LANG_TAG, + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions & { lang?: string } = {}, ): Intl.Locale { - return getLocaleWithGetter(() => getAcceptLanguages(request)[0] || lang) + return getLocaleWithGetter(() => + getHeaderLanguages(request, { name, parser })[0] || lang + ) } /** diff --git a/src/web.test.ts b/src/web.test.ts index 126c3b5..263b95f 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -1,52 +1,72 @@ import { describe, expect, test, vi } from 'vitest' import { - getAcceptLanguage, - getAcceptLanguages, - getAcceptLocale, - getAcceptLocales, getCookieLocale, + getHeaderLanguage, + getHeaderLanguages, + getHeaderLocale, + getHeaderLocales, getNavigatorLanguage, getNavigatorLanguages, setCookieLocale, } from './web.ts' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' -describe('getAcceptLanguages', () => { +describe('getHeaderLanguages', () => { test('basic', () => { const mockRequest = new Request('https://example.com') mockRequest.headers.set('accept-language', 'en-US,en;q=0.9,ja;q=0.8') - expect(getAcceptLanguages(mockRequest)).toEqual(['en-US', 'en', 'ja']) + expect(getHeaderLanguages(mockRequest)).toEqual(['en-US', 'en', 'ja']) }) test('any language', () => { const mockRequest = new Request('https://example.com') mockRequest.headers.set('accept-language', '*') - expect(getAcceptLanguages(mockRequest)).toEqual([]) + expect(getHeaderLanguages(mockRequest)).toEqual([]) }) test('empty', () => { const mockRequest = new Request('https://example.com') - expect(getAcceptLanguages(mockRequest)).toEqual([]) + expect(getHeaderLanguages(mockRequest)).toEqual([]) + }) + + test('custom header', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('x-inlitfy-language', 'en-US,en,ja') + expect(getHeaderLanguages(mockRequest, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + })).toEqual(['en-US', 'en', 'ja']) }) }) -describe('getAcceptLocales', () => { +describe('getHeaderLocales', () => { test('basic', () => { const mockRequest = new Request('https://example.com') mockRequest.headers.set('accept-language', 'en-US,en;q=0.9,ja;q=0.8') - expect(getAcceptLocales(mockRequest).map((locale) => locale.baseName)) + expect(getHeaderLocales(mockRequest).map((locale) => locale.baseName)) .toEqual(['en-US', 'en', 'ja']) }) test('any language', () => { const mockRequest = new Request('https://example.com') mockRequest.headers.set('accept-language', '*') - expect(getAcceptLocales(mockRequest)).toEqual([]) + expect(getHeaderLocales(mockRequest)).toEqual([]) }) test('empty', () => { const mockRequest = new Request('https://example.com') - expect(getAcceptLocales(mockRequest)).toEqual([]) + expect(getHeaderLocales(mockRequest)).toEqual([]) + }) + + test('custom header', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('x-inlitfy-language', 'en-US,en,ja') + expect( + getHeaderLanguage(mockRequest, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }), + ).toEqual('en-US') }) }) @@ -54,26 +74,37 @@ describe('getAcceptLanguage', () => { test('basic', () => { const mockRequest = new Request('https://example.com') mockRequest.headers.set('accept-language', 'en-US,en;q=0.9,ja;q=0.8') - expect(getAcceptLanguage(mockRequest)).toBe('en-US') + expect(getHeaderLanguage(mockRequest)).toBe('en-US') }) test('any language', () => { const mockRequest = new Request('https://example.com') mockRequest.headers.set('accept-language', '*') - expect(getAcceptLanguage(mockRequest)).toBe('') + expect(getHeaderLanguage(mockRequest)).toBe('') }) test('empty', () => { const mockRequest = new Request('https://example.com') - expect(getAcceptLanguage(mockRequest)).toBe('') + expect(getHeaderLanguage(mockRequest)).toBe('') + }) + + test('custom header', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('x-inlitfy-language', 'en-US,en,ja') + expect( + getHeaderLocales(mockRequest, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }).map((locale) => locale.baseName), + ).toEqual(['en-US', 'en', 'ja']) }) }) -describe('getAcceptLocale', () => { +describe('getHeaderLocale', () => { test('basic', () => { const mockRequest = new Request('https://example.com') mockRequest.headers.set('accept-language', 'en-US,en;q=0.9,ja;q=0.8') - const locale = getAcceptLocale(mockRequest) + const locale = getHeaderLocale(mockRequest) expect(locale.baseName).toEqual('en-US') expect(locale.language).toEqual('en') @@ -83,7 +114,7 @@ describe('getAcceptLocale', () => { test('accept-language is any language', () => { const mockRequest = new Request('https://example.com') mockRequest.headers.set('accept-language', '*') - const locale = getAcceptLocale(mockRequest) + const locale = getHeaderLocale(mockRequest) expect(locale.baseName).toEqual(DEFAULT_LANG_TAG) }) @@ -91,7 +122,7 @@ describe('getAcceptLocale', () => { test('specify default language', () => { const mockRequest = new Request('https://example.com') mockRequest.headers.set('accept-language', '*') - const locale = getAcceptLocale(mockRequest, 'ja-JP') + const locale = getHeaderLocale(mockRequest, { lang: 'ja-JP' }) expect(locale.baseName).toEqual('ja-JP') }) @@ -99,7 +130,20 @@ describe('getAcceptLocale', () => { test('RangeError', () => { const mockRequest = new Request('https://example.com') mockRequest.headers.set('accept-language', 's') - expect(() => getAcceptLocale(mockRequest, 'ja-JP')).toThrowError(RangeError) + expect(() => getHeaderLocale(mockRequest, { lang: 'ja-JP' })).toThrowError( + RangeError, + ) + }) + + test('custom header', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('x-inlitfy-language', 'en-US,en,ja') + expect( + getHeaderLocale(mockRequest, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }).toString(), + ).toEqual('en-US') }) }) diff --git a/src/web.ts b/src/web.ts index eb6b672..bc1e655 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,48 +1,61 @@ import { parse, serialize } from 'cookie-es' import { - getAcceptLanguagesWithGetter, getExistCookies, + getHeaderLanguagesWithGetter, getLocaleWithGetter, mapToLocaleFromLanguageTag, + parseDefaultHeader, validateLocale, } from './http.ts' -import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' +import { + ACCEPT_LANGUAGE_HEADER, + DEFAULT_COOKIE_NAME, + DEFAULT_LANG_TAG, +} from './constants.ts' -import type { CookieOptions } from './http.ts' +import type { CookieOptions, HeaderOptions } from './http.ts' /** - * get accpet languages + * get languages from header * - * @description parse `accept-language` header string + * @description parse header string, default `accept-language` header * * @example * example for Web API request on Deno: * * ```ts - * import { getAcceptLanguages } from 'https://esm.sh/@intlify/utils/web' + * import { getHeaderLanguages } from 'https://esm.sh/@intlify/utils/web' * * Deno.serve({ * port: 8080, * }, (req) => { - * const langTags = getAcceptLanguages(req) + * const langTags = getHeaderLanguages(req) * // ... * return new Response(`accepted languages: ${langTags.join(', ')}` * }) * ``` * * @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} The array of language tags, if `*` (any language) or empty string is detected, return an empty array. + * @returns {Array} The array of language tags, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. */ -export function getAcceptLanguages(request: Request): string[] { - const getter = () => request.headers.get('accept-language') - return getAcceptLanguagesWithGetter(getter) +export function getHeaderLanguages( + request: Request, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): string[] { + const getter = () => request.headers.get(name) + return getHeaderLanguagesWithGetter(getter, { name, parser }) } /** - * get accept language + * get language from header * - * @description parse `accept-language` header string, this function retuns the **first language tag** of `accept-language` header. + * @description parse header string, default `accept-language`. if you use `accept-language`, this function retuns the **first language tag** of `accept-language` header. * * @example * example for Web API request on Deno: @@ -53,34 +66,42 @@ export function getAcceptLanguages(request: Request): string[] { * Deno.serve({ * port: 8080, * }, (req) => { - * const langTag = getAcceptLanguage(req) + * const langTag = getHeaderLanguage(req) * // ... * return new Response(`accepted language: ${langTag}` * }) * ``` * * @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 {string} The **first language tag** of `accept-language` header, if `accept-language` header is not exists, or `*` (any language), return empty string. + * @returns {string} The **first language tag** of header, if header is not exists, or `*` (any language), return empty string. */ -export function getAcceptLanguage(request: Request): string { - return getAcceptLanguages(request)[0] || '' +export function getHeaderLanguage( + request: Request, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): string { + return getHeaderLanguages(request, { name, parser })[0] || '' } /** - * get locales from `accept-language` header + * get locales from header * - * @description wrap language tags with {@link Intl.Locale | locale} + * @description wrap language tags with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. * * @example * example for Web API request on Bun: * - * import { getAcceptLocales } from '@intlify/utils/web' + * import { getHeaderLocales } from '@intlify/utils/web' * * Bun.serve({ * port: 8080, * fetch(req) { - * const locales = getAcceptLocales(req) + * const locales = getHeaderLocales(req) * // ... * return new Response(`accpected locales: ${locales.map(locale => locale.toString()).join(', ')}`) * }, @@ -88,46 +109,63 @@ export function getAcceptLanguage(request: Request): string { * ``` * * @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} The locales that wrapped from `accept-language` header, if `*` (any language) or empty string is detected, return an empty array. + * @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 getAcceptLocales( +export function getHeaderLocales( request: Request, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, ): Intl.Locale[] { - return mapToLocaleFromLanguageTag(getAcceptLanguages, request) + return mapToLocaleFromLanguageTag(getHeaderLanguages, request, { + name, + parser, + }) } /** - * get locale from `accept-language` header + * get locale from header * - * @description wrap language tag with {@link Intl.Locale | locale} + * @description wrap language tag with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. * * @example * example for Web API request on Bun: * - * import { getAcceptLocale } from '@intlify/utils/web' + * import { getHeaderLocale } from '@intlify/utils/web' * * Bun.serve({ * port: 8080, * fetch(req) { - * const locale = getAcceptLocale(req) + * const locale = getHeaderLocale(req) * // ... * return new Response(`accpected locale: ${locale.toString()}`) * }, * }) * * @param {Request} request The {@link Request | request} - * @param {string} 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 {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. * - * @throws {RangeError} Throws the {@link RangeError} if `lang` option or `accpet-languages` are not a well-formed BCP 47 language tag. + * @throws {RangeError} Throws the {@link RangeError} if `lang` option or header are not a well-formed BCP 47 language tag. * - * @returns {Intl.Locale} The first locale that resolved from `accept-language` header string, first language tag is used. if `*` (any language) or empty string is detected, return `en-US`. + * @returns {Intl.Locale} 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`. */ -export function getAcceptLocale( +export function getHeaderLocale( request: Request, - lang = DEFAULT_LANG_TAG, + { + lang = DEFAULT_LANG_TAG, + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions & { lang?: string } = {}, ): Intl.Locale { - return getLocaleWithGetter(() => getAcceptLanguages(request)[0] || lang) + return getLocaleWithGetter(() => + getHeaderLanguages(request, { name, parser })[0] || lang + ) } /**