From 9ddc86beaf5c5515c228710d023b78cb30477de2 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 20 Sep 2023 20:12:13 +0900 Subject: [PATCH] feat: support runtime aginostic `getAcceptLanguages` (#4) * feat: add `parseAcceptLanguage` * feat: support framework/runtime aginostic `getAcceptLanguages` --- build.config.ts | 16 +++++++- bun.lockb | Bin 127064 -> 129687 bytes package.json | 16 ++++++++ src/h3.test.ts | 46 +++++++++++++++++++++++ src/h3.ts | 21 +++++++++++ src/http.ts | 8 ++++ src/index.ts | 44 +--------------------- src/node.test.ts | 30 +++++++++++++++ src/node.ts | 16 ++++++++ test/index.test.ts => src/shared.test.ts | 6 +-- src/shared.ts | 43 +++++++++++++++++++++ src/web.test.ts | 21 +++++++++++ src/web.ts | 15 ++++++++ 13 files changed, 233 insertions(+), 49 deletions(-) create mode 100644 src/h3.test.ts create mode 100644 src/h3.ts create mode 100644 src/http.ts create mode 100644 src/node.test.ts create mode 100644 src/node.ts rename test/index.test.ts => src/shared.test.ts (92%) create mode 100644 src/shared.ts create mode 100644 src/web.test.ts create mode 100644 src/web.ts diff --git a/build.config.ts b/build.config.ts index d20a7a9..3527223 100644 --- a/build.config.ts +++ b/build.config.ts @@ -5,5 +5,19 @@ export default defineBuildConfig({ rollup: { emitCJS: true, }, - entries: ['./src/index.ts'], + entries: [ + { + input: './src/index.ts', + }, + { + input: './src/h3.ts', + }, + { + input: './src/node.ts', + }, + { + input: './src/web.ts', + }, + ], + externals: ['h3'], }) diff --git a/bun.lockb b/bun.lockb index 1b0935cbb9bdd5841fbc47dd0e6fec04124a5ef8..5e0dcf38b1e7a4b4d2fd5b31d7689295a890a83a 100755 GIT binary patch delta 11715 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 delta 10141 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 diff --git a/package.json b/package.json index 288abaa..f1aba0d 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,21 @@ "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, + "./h3": { + "types": "./dist/h3.d.ts", + "import": "./dist/h3.mjs", + "require": "./dist/h3.cjs" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.mjs", + "require": "./dist/node.cjs" + }, + "./web": { + "types": "./dist/web.d.ts", + "import": "./dist/web.mjs", + "require": "./dist/web.cjs" + }, "./dist/*": "./dist/*", "./package.json": "./package.json" }, @@ -67,6 +82,7 @@ "@vitest/coverage-v8": "^0.34.4", "bumpp": "^9.2.0", "gh-changelogen": "^0.2.8", + "h3": "^1.8.1", "lint-staged": "^14.0.0", "typescript": "^5.2.2", "unbuild": "^2.0.0", diff --git a/src/h3.test.ts b/src/h3.test.ts new file mode 100644 index 0000000..d5f3a04 --- /dev/null +++ b/src/h3.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest' +import { getAcceptLanguages } from './h3.ts' + +import type { H3Event } from 'h3' + +describe('getAcceptLanguages', () => { + test('basic', () => { + const eventMock = { + node: { + req: { + method: 'GET', + headers: { + 'accept-language': 'en-US,en;q=0.9,ja;q=0.8', + }, + }, + }, + } as H3Event + expect(getAcceptLanguages(eventMock)).toEqual(['en-US', 'en', 'ja']) + }) + + test('any language', () => { + const eventMock = { + node: { + req: { + method: 'GET', + headers: { + 'accept-language': '*', + }, + }, + }, + } as H3Event + expect(getAcceptLanguages(eventMock)).toEqual([]) + }) + + test('empty', () => { + const eventMock = { + node: { + req: { + method: 'GET', + headers: {}, + }, + }, + } as H3Event + expect(getAcceptLanguages(eventMock)).toEqual([]) + }) +}) diff --git a/src/h3.ts b/src/h3.ts new file mode 100644 index 0000000..c63d42b --- /dev/null +++ b/src/h3.ts @@ -0,0 +1,21 @@ +import { getAcceptLanguagesFromGetter } from './http.ts' +import { getHeaders } from 'h3' + +import type { H3Event } from 'h3' + +/** + * get accpet languages + * + * @description parse `accept-language` header string + * + * @param {H3Event} event The {@link H3Event | H3} event + * + * @returns {Array} The array of language tags, if `*` (any language) or empty string is detected, return an empty array. + */ +export function getAcceptLanguages(event: H3Event): string[] { + const getter = () => { + const headers = getHeaders(event) + return headers['accept-language'] + } + return getAcceptLanguagesFromGetter(getter) +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..9e48954 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,8 @@ +import { parseAcceptLanguage } from './shared.ts' + +export function getAcceptLanguagesFromGetter( + getter: () => string | null | undefined, +): string[] { + const acceptLanguage = getter() + return acceptLanguage ? parseAcceptLanguage(acceptLanguage) : [] +} diff --git a/src/index.ts b/src/index.ts index 980dfdb..34e93b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,43 +1 @@ -const objectToString = Object.prototype.toString -const toTypeString = (value: unknown): string => objectToString.call(value) - -/** - * check whether the value is a {@link Intl.Locale} instance - * - * @param {unknown} val The locale value - * - * @returns {boolean} Returns `true` if the value is a {@link Intl.Locale} instance, else `false`. - */ -export function isLocale(val: unknown): val is Intl.Locale { - return toTypeString(val) === '[object Intl.Locale]' -} - -/** - * parse `accept-language` header string - * - * @param {string} value The accept-language header string - * - * @returns {Array} The array of language tags, if `*` (any language) or empty string is detected, return an empty array. - */ -export function parseAcceptLanguage(value: string): string[] { - return value.split(',').map((tag) => tag.split(';')[0]).filter((tag) => - !(tag === '*' || tag === '') - ) -} - -/** - * validate the language tag whether is a well-formed {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 language tag}. - * - * @param {string} lang a language tag - * - * @returns {boolean} Returns `true` if the language tag is valid, else `false`. - */ -export function validateLanguageTag(lang: string): boolean { - try { - // TODO: if we have a better way to validate the language tag, we should use it. - new Intl.Locale(lang) - return true - } catch { - return false - } -} +export * from './shared.ts' diff --git a/src/node.test.ts b/src/node.test.ts new file mode 100644 index 0000000..04d59a1 --- /dev/null +++ b/src/node.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'vitest' +import { getAcceptLanguages } from './node.ts' +import { IncomingMessage } from 'node:http' + +describe('getAcceptLanguages', () => { + 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']) + }) + + test('any language', () => { + const mockRequest = { + headers: { + 'accept-language': '*', + }, + } as IncomingMessage + expect(getAcceptLanguages(mockRequest)).toEqual([]) + }) + + test('empty', () => { + const mockRequest = { + headers: {}, + } as IncomingMessage + expect(getAcceptLanguages(mockRequest)).toEqual([]) + }) +}) diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 0000000..2d1bb6c --- /dev/null +++ b/src/node.ts @@ -0,0 +1,16 @@ +import { IncomingMessage } from 'node:http' +import { getAcceptLanguagesFromGetter } from './http.ts' + +/** + * get accpet languages + * + * @description parse `accept-language` header string + * + * @param {IncomingMessage} event The {@link IncomingMessage | request} + * + * @returns {Array} The array of language tags, if `*` (any language) or empty string is detected, return an empty array. + */ +export function getAcceptLanguages(req: IncomingMessage) { + const getter = () => req.headers['accept-language'] + return getAcceptLanguagesFromGetter(getter) +} diff --git a/test/index.test.ts b/src/shared.test.ts similarity index 92% rename from test/index.test.ts rename to src/shared.test.ts index 2ca08d6..85d2923 100644 --- a/test/index.test.ts +++ b/src/shared.test.ts @@ -1,9 +1,5 @@ import { describe, expect, test } from 'vitest' -import { - isLocale, - parseAcceptLanguage, - validateLanguageTag, -} from '../src/index.ts' +import { isLocale, parseAcceptLanguage, validateLanguageTag } from './shared.ts' describe('isLocale', () => { test('Locale instance', () => { diff --git a/src/shared.ts b/src/shared.ts new file mode 100644 index 0000000..980dfdb --- /dev/null +++ b/src/shared.ts @@ -0,0 +1,43 @@ +const objectToString = Object.prototype.toString +const toTypeString = (value: unknown): string => objectToString.call(value) + +/** + * check whether the value is a {@link Intl.Locale} instance + * + * @param {unknown} val The locale value + * + * @returns {boolean} Returns `true` if the value is a {@link Intl.Locale} instance, else `false`. + */ +export function isLocale(val: unknown): val is Intl.Locale { + return toTypeString(val) === '[object Intl.Locale]' +} + +/** + * parse `accept-language` header string + * + * @param {string} value The accept-language header string + * + * @returns {Array} The array of language tags, if `*` (any language) or empty string is detected, return an empty array. + */ +export function parseAcceptLanguage(value: string): string[] { + return value.split(',').map((tag) => tag.split(';')[0]).filter((tag) => + !(tag === '*' || tag === '') + ) +} + +/** + * validate the language tag whether is a well-formed {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 language tag}. + * + * @param {string} lang a language tag + * + * @returns {boolean} Returns `true` if the language tag is valid, else `false`. + */ +export function validateLanguageTag(lang: string): boolean { + try { + // TODO: if we have a better way to validate the language tag, we should use it. + new Intl.Locale(lang) + return true + } catch { + return false + } +} diff --git a/src/web.test.ts b/src/web.test.ts new file mode 100644 index 0000000..aa8ebe0 --- /dev/null +++ b/src/web.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest' +import { getAcceptLanguages } from './web.ts' + +describe('getAcceptLanguages', () => { + 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']) + }) + + test('any language', () => { + const mockRequest = new Request('https://example.com') + mockRequest.headers.set('accept-language', '*') + expect(getAcceptLanguages(mockRequest)).toEqual([]) + }) + + test('empty', () => { + const mockRequest = new Request('https://example.com') + expect(getAcceptLanguages(mockRequest)).toEqual([]) + }) +}) diff --git a/src/web.ts b/src/web.ts new file mode 100644 index 0000000..19a9e69 --- /dev/null +++ b/src/web.ts @@ -0,0 +1,15 @@ +import { getAcceptLanguagesFromGetter } from './http.ts' + +/** + * get accpet languages + * + * @description parse `accept-language` header string + * + * @param {Request} event The {@link Request | request} + * + * @returns {Array} The array of language tags, if `*` (any language) or empty string is detected, return an empty array. + */ +export function getAcceptLanguages(req: Request) { + const getter = () => req.headers.get('accept-language') + return getAcceptLanguagesFromGetter(getter) +}