diff --git a/build.config.ts b/build.config.ts index 3527223..807357a 100644 --- a/build.config.ts +++ b/build.config.ts @@ -4,6 +4,7 @@ export default defineBuildConfig({ declaration: true, rollup: { emitCJS: true, + inlineDependencies: true, }, entries: [ { diff --git a/bun.lockb b/bun.lockb index 5e0dcf3..9c7d85d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index bf1d921..d42c720 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@types/node": "^20.6.0", "@vitest/coverage-v8": "^0.34.4", "bumpp": "^9.2.0", + "cookie-es": "^1.0.0", "gh-changelogen": "^0.2.8", "h3": "^1.8.1", "lint-staged": "^14.0.0", diff --git a/src/h3.test.ts b/src/h3.test.ts index 6133252..31b9e02 100644 --- a/src/h3.test.ts +++ b/src/h3.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { getAcceptLanguages, getLocale } from './h3.ts' +import { getAcceptLanguages, getCookieLocale, getLocale } from './h3.ts' import type { H3Event } from 'h3' @@ -111,3 +111,83 @@ describe('getLocale', () => { expect(() => getLocale(eventMock, 'ja-JP')).toThrowError(RangeError) }) }) + +describe('getCookieLocale', () => { + test('basic', () => { + const eventMock = { + node: { + req: { + method: 'GET', + headers: { + cookie: 'i18n_locale=ja-US', + }, + }, + }, + } as H3Event + const locale = getCookieLocale(eventMock) + + expect(locale.baseName).toEqual('ja-US') + expect(locale.language).toEqual('ja') + expect(locale.region).toEqual('US') + }) + + test('cookie is empty', () => { + const eventMock = { + node: { + req: { + method: 'GET', + headers: {}, + }, + }, + } as H3Event + const locale = getCookieLocale(eventMock) + + expect(locale.baseName).toEqual('en-US') + }) + + test('specify default language', () => { + const eventMock = { + node: { + req: { + method: 'GET', + headers: {}, + }, + }, + } as H3Event + const locale = getCookieLocale(eventMock, { lang: 'ja-JP' }) + + expect(locale.baseName).toEqual('ja-JP') + }) + + test('specify cookie name', () => { + const eventMock = { + node: { + req: { + method: 'GET', + headers: { + cookie: 'intlify_locale=fr-FR', + }, + }, + }, + } as H3Event + const locale = getCookieLocale(eventMock, { name: 'intlify_locale' }) + + expect(locale.baseName).toEqual('fr-FR') + }) + + test('RangeError', () => { + const eventMock = { + node: { + req: { + method: 'GET', + headers: { + cookie: 'intlify_locale=f', + }, + }, + }, + } as H3Event + + expect(() => getCookieLocale(eventMock, { name: 'intlify_locale' })) + .toThrowError(RangeError) + }) +}) diff --git a/src/h3.ts b/src/h3.ts index bfaffe1..e53590f 100644 --- a/src/h3.ts +++ b/src/h3.ts @@ -1,5 +1,5 @@ import { getAcceptLanguagesFromGetter, getLocaleWithGetter } from './http.ts' -import { getHeaders } from 'h3' +import { getCookie, getHeaders } from 'h3' import type { H3Event } from 'h3' @@ -9,7 +9,7 @@ import type { H3Event } from 'h3' * @description parse `accept-language` header string * * @example - * example for h3 event: + * example for h3: * * ```ts * import { createApp, eventHandler } from 'h3' @@ -38,11 +38,11 @@ export function getAcceptLanguages(event: H3Event): string[] { * get locale * * @example - * example for h3 event: + * example for h3: * * ```ts * import { createApp, eventHandler } from 'h3' - * import { getAcceptLanguages } from '@intlify/utils/h3' + * import { getLocale } from '@intlify/utils/h3' * * app.use(eventHandler(event) => { * const locale = getLocale(event) @@ -61,3 +61,35 @@ export function getAcceptLanguages(event: H3Event): string[] { export function getLocale(event: H3Event, lang = 'en-US'): Intl.Locale { return getLocaleWithGetter(() => getAcceptLanguages(event)[0] || lang) } + +/** + * get locale from cookie + * + * @example + * example for h3: + * + * ```ts + * import { createApp, eventHandler } from 'h3' + * import { getCookieLocale } from '@intlify/utils/h3' + * + * app.use(eventHandler(event) => { + * const locale = getCookieLocale(event) + * console.log(locale) // output `Intl.Locale` instance + * // ... + * }) + * ``` + * + * @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` + * + * @throws {RangeError} Throws a {@link RangeError} if `lang` option or cookie name value are not a well-formed BCP 47 language tag. + * + * @returns The locale that resolved from cookie + */ +export function getCookieLocale( + event: H3Event, + { lang = 'en-US', name = 'i18n_locale' } = {}, +): Intl.Locale { + return getLocaleWithGetter(() => getCookie(event, name) || lang) +} diff --git a/src/node.test.ts b/src/node.test.ts index 41404dc..0a8ef3a 100644 --- a/src/node.test.ts +++ b/src/node.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { getAcceptLanguages, getLocale } from './node.ts' +import { getAcceptLanguages, getCookieLocale, getLocale } from './node.ts' import { IncomingMessage } from 'node:http' describe('getAcceptLanguages', () => { @@ -74,3 +74,60 @@ describe('getLocale', () => { expect(() => getLocale(mockRequest, 'ja-JP')).toThrowError(RangeError) }) }) + +describe('getCookieLocale', () => { + test('basic', () => { + const mockRequest = { + headers: { + cookie: 'i18n_locale=ja-US', + }, + } as IncomingMessage + const locale = getCookieLocale(mockRequest) + + expect(locale.baseName).toEqual('ja-US') + expect(locale.language).toEqual('ja') + expect(locale.region).toEqual('US') + }) + + test('cookie is empty', () => { + const mockRequest = { + headers: { + cookie: '', + }, + } as IncomingMessage + const locale = getCookieLocale(mockRequest) + + expect(locale.baseName).toEqual('en-US') + }) + + test('specify default language', () => { + const mockRequest = { + headers: {}, + } as IncomingMessage + const locale = getCookieLocale(mockRequest, { lang: 'ja-JP' }) + + expect(locale.baseName).toEqual('ja-JP') + }) + + test('specify cookie name', () => { + const mockRequest = { + headers: { + cookie: 'intlify_locale=fr-FR', + }, + } as IncomingMessage + const locale = getCookieLocale(mockRequest, { name: 'intlify_locale' }) + + expect(locale.baseName).toEqual('fr-FR') + }) + + test('RangeError', () => { + const mockRequest = { + headers: { + cookie: 'intlify_locale=f', + }, + } as IncomingMessage + + expect(() => getCookieLocale(mockRequest, { name: 'intlify_locale' })) + .toThrowError(RangeError) + }) +}) diff --git a/src/node.ts b/src/node.ts index a453b88..c64ed60 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,4 +1,5 @@ import { IncomingMessage } from 'node:http' +import { parse } from 'cookie-es' import { getAcceptLanguagesFromGetter, getLocaleWithGetter } from './http.ts' /** @@ -19,7 +20,7 @@ import { getAcceptLanguagesFromGetter, getLocaleWithGetter } from './http.ts' * }) * ``` * - * @param {IncomingMessage} event The {@link IncomingMessage | request} + * @param {IncomingMessage} request The {@link IncomingMessage | request} * * @returns {Array} The array of language tags, if `*` (any language) or empty string is detected, return an empty array. */ @@ -36,7 +37,7 @@ export function getAcceptLanguages(req: IncomingMessage) { * * ```ts * import { createServer } from 'node:http' - * import { getAcceptLanguages } from '@intlify/utils/node' + * import { getLocale } from '@intlify/utils/node' * * const server = createServer((req, res) => { * const locale = getLocale(req) @@ -45,7 +46,7 @@ export function getAcceptLanguages(req: IncomingMessage) { * }) * ``` * - * @param {IncomingMessage} event The {@link IncomingMessage | request} + * @param {IncomingMessage} request The {@link IncomingMessage | request} * @param {string} 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}. * * @throws {RangeError} Throws a {@link RangeError} if `lang` option or `accpet-languages` are not a well-formed BCP 47 language tag. @@ -55,3 +56,40 @@ export function getAcceptLanguages(req: IncomingMessage) { export function getLocale(req: IncomingMessage, lang = 'en-US'): Intl.Locale { return getLocaleWithGetter(() => getAcceptLanguages(req)[0] || lang) } + +/** + * get locale from cookie + * + * @example + * example for Node.js request: + * + * ```ts + * import { createServer } from 'node:http' + * import { getCookieLocale } from '@intlify/utils/node' + * + * const server = createServer((req, res) => { + * const locale = getCookieLocale(req) + * console.log(locale) // output `Intl.Locale` instance + * // ... + * }) + * ``` + * + * @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` + * + * @throws {RangeError} Throws a {@link RangeError} if `lang` option or cookie name value are not a well-formed BCP 47 language tag. + * + * @returns The locale that resolved from cookie + */ +export function getCookieLocale( + req: IncomingMessage, + { lang = 'en-US', name = 'i18n_locale' } = {}, +): Intl.Locale { + const getter = () => { + const cookieRaw = req.headers.cookie + const cookie = parse(cookieRaw || '') + return cookie[name] || lang + } + return getLocaleWithGetter(getter) +} diff --git a/src/web.test.ts b/src/web.test.ts index 5dd8f6a..b6bd611 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { getAcceptLanguages, getLocale } from './web.ts' +import { getAcceptLanguages, getCookieLocale, getLocale } from './web.ts' describe('getAcceptLanguages', () => { test('basic', () => { @@ -53,3 +53,45 @@ describe('getLocale', () => { expect(() => getLocale(mockRequest, 'ja-JP')).toThrowError(RangeError) }) }) + +describe('getCookieLocale', () => { + test('basic', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('cookie', 'i18n_locale=ja-US') + const locale = getCookieLocale(mockRequest) + + expect(locale.baseName).toEqual('ja-US') + expect(locale.language).toEqual('ja') + expect(locale.region).toEqual('US') + }) + + test('cookie is empty', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('cookie', '') + const locale = getCookieLocale(mockRequest) + + expect(locale.baseName).toEqual('en-US') + }) + + test('specify default language', () => { + const mockRequest = new Request('https://example.com') + const locale = getCookieLocale(mockRequest, { lang: 'ja-JP' }) + + expect(locale.baseName).toEqual('ja-JP') + }) + + test('specify cookie name', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('cookie', 'intlify_locale=fr-FR') + const locale = getCookieLocale(mockRequest, { name: 'intlify_locale' }) + + expect(locale.baseName).toEqual('fr-FR') + }) + + test('RangeError', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('cookie', 'intlify_locale=f') + expect(() => getCookieLocale(mockRequest, { name: 'intlify_locale' })) + .toThrowError(RangeError) + }) +}) diff --git a/src/web.ts b/src/web.ts index 3198a47..088a072 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,3 +1,4 @@ +import { parse } from 'cookie-es' import { getAcceptLanguagesFromGetter, getLocaleWithGetter } from './http.ts' /** @@ -20,7 +21,7 @@ import { getAcceptLanguagesFromGetter, getLocaleWithGetter } from './http.ts' * }) * ``` * - * @param {Request} event The {@link Request | request} + * @param {Request} request The {@link Request | request} * * @returns {Array} The array of language tags, if `*` (any language) or empty string is detected, return an empty array. */ @@ -35,7 +36,7 @@ export function getAcceptLanguages(req: Request) { * @example * example for Web API request on Bun: * - * import { getAcceptLanguages } from '@intlify/utils/web' + * import { getLocale } from '@intlify/utils/web' * * Bun.serve({ * port: 8080, @@ -46,7 +47,7 @@ export function getAcceptLanguages(req: Request) { * }, * }) * - * @param {Request} event The {@link Request | request} + * @param {Request} request The {@link Request | request} * @param {string} 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}. * * @throws {RangeError} Throws a {@link RangeError} if `lang` option or `accpet-languages` are not a well-formed BCP 47 language tag. @@ -56,3 +57,41 @@ export function getAcceptLanguages(req: Request) { export function getLocale(req: Request, lang = 'en-US'): Intl.Locale { return getLocaleWithGetter(() => getAcceptLanguages(req)[0] || lang) } + +/** + * get locale from cookie + * + * @example + * example for Web API request on Deno: + * + * ```ts + * import { getCookieLocale } from 'https://esm.sh/@intlify/utils/web' + * + * Deno.serve({ + * port: 8080, + * }, (req) => { + * const locale = getCookieLocale(req) + * console.log(locale) // output `Intl.Locale` instance + * // ... + * }) + * ``` + * + * @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` + * + * @throws {RangeError} Throws a {@link RangeError} if `lang` option or cookie name value are not a well-formed BCP 47 language tag. + * + * @returns The locale that resolved from cookie + */ +export function getCookieLocale( + req: Request, + { lang = 'en-US', name = 'i18n_locale' } = {}, +): Intl.Locale { + const getter = () => { + const cookieRaw = req.headers.get('cookie') + const cookie = parse(cookieRaw || '') + return cookie[name] || lang + } + return getLocaleWithGetter(getter) +}