diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0959ee9..7ba3384 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,12 @@ jobs: - name: Install dependencies run: bun install + # NOTE: avoid https://github.com/intlify/utils/actions/runs/6573605958/job/17857030689?pr=31#step:8:48 + # vitest-environment-miniflare tries to load dist/index.cjs and work with vitest... - name: Build codes + run: npm run build + + - name: Test run: npm test edge-release: diff --git a/build.config.ts b/build.config.ts index 5c44a48..60d2812 100644 --- a/build.config.ts +++ b/build.config.ts @@ -16,9 +16,12 @@ export default defineBuildConfig({ { input: './src/h3.ts', }, + { + input: './src/hono.ts', + }, { input: './src/node.ts', }, ], - externals: ['h3'], + externals: ['h3', 'hono'], }) diff --git a/bun.lockb b/bun.lockb index f407478..659ada2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index d7dd55d..825826c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,12 @@ "require": "./dist/h3.cjs", "default": "./dist/h3.mjs" }, + "./hono": { + "types": "./dist/hono.d.ts", + "import": "./dist/hono.mjs", + "require": "./dist/hono.cjs", + "default": "./dist/hono.mjs" + }, "./node": { "types": "./dist/node.d.ts", "import": "./dist/node.mjs", @@ -68,7 +74,7 @@ "build": "unbuild", "test": "npm run test:typecheck && npm run test:unit", "test:unit": "vitest run", - "test:typecheck": "vitest typecheck --run", + "test:typecheck": "NODE_OPTIONS=--experimental-vm-modules vitest typecheck --run", "test:coverage": "npm test -- --reporter verbose --coverage", "play:browser": "npm run -w example-browser dev", "play:node": "npm run -w example-node dev", @@ -84,19 +90,23 @@ ] }, "devDependencies": { + "@cloudflare/workers-types": "^4.20231016.0", "@types/node": "^20.6.0", "@types/supertest": "^2.0.12", - "@vitest/coverage-v8": "^1.0.0-beta.1", - "bun-types": "latest", + "@vitest/coverage-v8": "^0.34.6", "bumpp": "^9.2.0", + "bun-types": "latest", "cookie-es": "^1.0.0", "gh-changelogen": "^0.2.8", "h3": "^1.8.1", + "hono": "^3.8.1", "lint-staged": "^15.0.0", + "miniflare": "^3.20231016.0", "supertest": "^6.3.3", "typescript": "^5.2.2", "unbuild": "^2.0.0", - "vitest": "^1.0.0-beta.1" + "vitest": "^0.34.6", + "vitest-environment-miniflare": "^2.14.1" }, "workspaces": [ "playground/*" diff --git a/src/hono.test.ts b/src/hono.test.ts new file mode 100644 index 0000000..c1cbe08 --- /dev/null +++ b/src/hono.test.ts @@ -0,0 +1,373 @@ +// @vitest-environment miniflare +import { describe, expect, test } from 'vitest' +import { parseAcceptLanguage } from './shared.ts' +import { + getCookieLocale, + getHeaderLanguage, + getHeaderLanguages, + getHeaderLocale, + getHeaderLocales, + getPathLocale, + getQueryLocale, + setCookieLocale, +} from './hono.ts' +import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' +import { Hono } from 'hono' + +import type { Context } from 'hono' + +describe('getHeaderLanguages', () => { + test('basic', () => { + const mockContext = { + req: { + header: (_name) => 'en-US,en;q=0.9,ja;q=0.8', + }, + } as Context + expect(getHeaderLanguages(mockContext)).toEqual(['en-US', 'en', 'ja']) + }) + + test('any language', () => { + const mockContext = { + req: { + header: (_name) => '*', + }, + } as Context + expect(getHeaderLanguages(mockContext)).toEqual([]) + }) + + test('empty', () => { + const mockContext = { + req: { + header: (_name) => undefined, + }, + } as Context + expect(getHeaderLanguages(mockContext)).toEqual([]) + }) + + test('parse option', () => { + const mockContext = { + req: { + header: (_name) => 'en-US,en;q=0.9,ja;q=0.8', + }, + } as Context + expect(getHeaderLanguages(mockContext, { parser: parseAcceptLanguage })) + .toEqual(['en-US', 'en', 'ja']) + }) + + test('custom header', () => { + const mockContext = { + req: { + header: (_name) => 'en-US,en,ja', + }, + } as Context + expect( + getHeaderLanguages(mockContext, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }), + ).toEqual(['en-US', 'en', 'ja']) + }) +}) + +describe('getAcceptLanguage', () => { + test('basic', () => { + const mockContext = { + req: { + header: (_name) => 'en-US,en;q=0.9,ja;q=0.8', + }, + } as Context + expect(getHeaderLanguage(mockContext)).toEqual('en-US') + }) + + test('any language', () => { + const mockContext = { + req: { + header: (_name) => '*', + }, + } as Context + expect(getHeaderLanguage(mockContext)).toEqual('') + }) + + test('empty', () => { + const mockContext = { + req: { + header: (_name) => undefined, + }, + } as Context + expect(getHeaderLanguage(mockContext)).toEqual('') + }) + + test('custom header', () => { + const mockContext = { + req: { + header: (_name) => 'en-US,en,ja', + }, + } as Context + expect( + getHeaderLanguage(mockContext, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }), + ).toEqual('en-US') + }) +}) + +describe('getHeaderLocales', () => { + test('basic', () => { + const mockContext = { + req: { + header: (_name) => 'en-US,en;q=0.9,ja;q=0.8', + }, + } as Context + expect(getHeaderLocales(mockContext).map((locale) => locale.baseName)) + .toEqual(['en-US', 'en', 'ja']) + }) + + test('any language', () => { + const mockContext = { + req: { + header: (_name) => '*', + }, + } as Context + expect(getHeaderLocales(mockContext)).toEqual([]) + }) + + test('empty', () => { + const mockContext = { + req: { + header: (_name) => undefined, + }, + } as Context + expect(getHeaderLocales(mockContext)).toEqual([]) + }) + + test('custom header', () => { + const mockContext = { + req: { + header: (_name) => 'en-US,en,ja', + }, + } as Context + expect( + getHeaderLocales(mockContext, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }).map((locale) => locale.baseName), + ).toEqual(['en-US', 'en', 'ja']) + }) +}) + +describe('getHeaderLocale', () => { + test('basic', () => { + const mockContext = { + req: { + header: (_name) => 'en-US,en;q=0.9,ja;q=0.8', + }, + } as Context + const locale = getHeaderLocale(mockContext) + + expect(locale.baseName).toEqual('en-US') + expect(locale.language).toEqual('en') + expect(locale.region).toEqual('US') + }) + + test('accept-language is any language', () => { + const mockContext = { + req: { + header: (_name) => '*', + }, + } as Context + const locale = getHeaderLocale(mockContext) + + expect(locale.baseName).toEqual(DEFAULT_LANG_TAG) + }) + + test('specify default language', () => { + const mockContext = { + req: { + header: (_name) => '*', + }, + } as Context + const locale = getHeaderLocale(mockContext, { lang: 'ja-JP' }) + + expect(locale.baseName).toEqual('ja-JP') + }) + + test('RangeError', () => { + const mockContext = { + req: { + header: (_name) => 'x', + }, + } as Context + + expect(() => getHeaderLocale(mockContext, { lang: 'ja-JP' })).toThrowError( + RangeError, + ) + }) + + test('custom header', () => { + const mockContext = { + req: { + header: (_name) => 'en-US,en,ja', + }, + } as Context + expect( + getHeaderLocale(mockContext, { + name: 'x-inlitfy-language', + parser: (header) => header.split(','), + }).toString(), + ).toEqual('en-US') + }) +}) + +describe('getCookieLocale', () => { + test('basic', () => { + const mockContext = { + req: { + raw: { + headers: { + get: (_name) => `${DEFAULT_COOKIE_NAME}=ja-US`, + }, + }, + }, + } as Context + const locale = getCookieLocale(mockContext) + + expect(locale.baseName).toEqual('ja-US') + expect(locale.language).toEqual('ja') + expect(locale.region).toEqual('US') + }) + + test('cookie is empty', () => { + const mockContext = { + req: { + raw: { + headers: { + get: (_name) => '', + }, + }, + }, + } as Context + const locale = getCookieLocale(mockContext) + + expect(locale.baseName).toEqual(DEFAULT_LANG_TAG) + }) + + test('specify default language', () => { + const mockContext = { + req: { + raw: { + headers: { + get: (_name) => '', + }, + }, + }, + } as Context + const locale = getCookieLocale(mockContext, { lang: 'ja-JP' }) + + expect(locale.baseName).toEqual('ja-JP') + }) + + test('specify cookie name', () => { + const mockContext = { + req: { + raw: { + headers: { + get: (_name) => 'intlify_locale=fr-FR', + }, + }, + }, + } as Context + const locale = getCookieLocale(mockContext, { name: 'intlify_locale' }) + + expect(locale.baseName).toEqual('fr-FR') + }) + + test('RangeError', () => { + const mockContext = { + req: { + raw: { + headers: { + get: (_name) => 'intlify_locale=f', + }, + }, + }, + } as Context + + expect(() => getCookieLocale(mockContext, { name: 'intlify_locale' })) + .toThrowError(RangeError) + }) +}) + +describe('setCookieLocale', () => { + test('specify Locale instance', async () => { + const app = new Hono() + app.get('/', (c) => { + const locale = new Intl.Locale('ja-JP') + setCookieLocale(c, locale, { name: DEFAULT_COOKIE_NAME, path: '/' }) + return c.text(locale.toString()) + }) + const res = await app.request('http://localhost/') + expect(res.headers.getSetCookie()).toEqual([ + `${DEFAULT_COOKIE_NAME}=ja-JP; Path=/`, + ]) + }) + + test('specify language tag', async () => { + const app = new Hono() + app.get('/', (c) => { + setCookieLocale(c, 'ja-JP', { name: DEFAULT_COOKIE_NAME, path: '/' }) + return c.text('') + }) + const res = await app.request('http://localhost/') + expect(res.headers.getSetCookie()).toEqual([ + `${DEFAULT_COOKIE_NAME}=ja-JP; Path=/`, + ]) + }) + + test('specify cookie name', async () => { + const app = new Hono() + app.get('/', (c) => { + setCookieLocale(c, 'ja-JP', { name: 'intlify_locale', path: '/' }) + return c.text('') + }) + const res = await app.request('http://localhost/') + expect(res.headers.getSetCookie()).toEqual([ + `intlify_locale=ja-JP; Path=/`, + ]) + }) + + test('Syntax Error', () => { + const mockContext = { + req: { + raw: { + headers: { + get: (_name) => '', + }, + }, + }, + } as Context + + expect(() => setCookieLocale(mockContext, 'j')) + .toThrowError(/locale is invalid: j/) + }) +}) + +test('getPathLocale', async () => { + const app = new Hono() + app.get('*', (c) => { + return c.json({ locale: getPathLocale(c).toString() }) + }) + const res = await app.request('http://localhost/en/foo') + const result = await res.json() + expect(result).toEqual({ locale: 'en' }) +}) + +test('getQueryLocale', async () => { + const app = new Hono() + app.get('/', (c) => { + return c.json({ locale: getQueryLocale(c).toString() }) + }) + const res = await app.request('http://localhost/?locale=ja') + const result = await res.json() + expect(result).toEqual({ locale: 'ja' }) +}) diff --git a/src/hono.ts b/src/hono.ts new file mode 100644 index 0000000..b0bf396 --- /dev/null +++ b/src/hono.ts @@ -0,0 +1,272 @@ +import { + ACCEPT_LANGUAGE_HEADER, + DEFAULT_COOKIE_NAME, + DEFAULT_LANG_TAG, +} from './constants.ts' +import { + getHeaderLanguagesWithGetter, + getLocaleWithGetter, + getPathLocale as _getPathLocale, + getQueryLocale as _getQueryLocale, + mapToLocaleFromLanguageTag, + parseDefaultHeader, + validateLocale, +} from './http.ts' +import { pathLanguageParser } from './shared.ts' +import { getCookie, setCookie } from 'hono/cookie' + +import type { Context } from 'hono' +import type { HeaderOptions, PathOptions, QueryOptions } from './http.ts' + +type CookieOptions = Parameters[3] & { name?: string } + +/** + * get languages from header + * + * @description parse header string, default `accept-language` header + * + * @example + * example for Hono + * + * ```ts + * import { Hono } from 'hono' + * import { getHeaderLanguages } from '@intlify/utils/hono' + * + * const app = new Hono() + * app.use('/', c => { + * const langTags = getHeaderLanguages(c) + * // ... + * return c.text(`accepted languages: ${acceptLanguages.join(', ')}`) + * }) + * ``` + * + * @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} An array of language tags, if you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. + */ +export function getHeaderLanguages(context: Context, { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, +}: HeaderOptions = {}): string[] { + return getHeaderLanguagesWithGetter(() => context.req.header(name), { + name, + parser, + }) +} + +/** + * get language from 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 Hone: + * + * ```ts + * import { Hono } from 'hono' + * import { getHeaderLanguage } from '@intlify/utils/hono' + * + * const app = new Hono() + * app.use('/', c => { + * const langTag = getHeaderLanguage(c) + * // ... + * return c.text(`accepted language: ${langTag}`) + * }) + * ``` + * + * @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 {string} A **first language tag** of header, if header is not exists, or `*` (any language), return empty string. + */ +export function getHeaderLanguage(context: Context, { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, +}: HeaderOptions = {}): string { + return getHeaderLanguages(context, { name, parser })[0] || '' +} + +/** + * get locales from header + * + * @description wrap language tags with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. + * + * @example + * example for Hono: + * + * ```ts + * import { Hono } from 'hono' + * import { getHeaderLocales } from '@intlify/utils/hono' + * + * const app = new Hono() + * app.use('/', c => { + * const locales = getHeaderLocales(c) + * // ... + * return c.text(`accepted locales: ${locales.map(locale => locale.toString()).join(', ')}`) + * }) + * ``` + * + * @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} 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( + context: Context, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): Intl.Locale[] { + return mapToLocaleFromLanguageTag(getHeaderLanguages, context, { + name, + parser, + }) +} + +/** + * get locale from header + * + * @description wrap language tag with {@link Intl.Locale | locale}, languages tags will be parsed from `accept-language` header as default. + * + * @example + * example for Hono: + * + * ```ts + * import { Hono } from 'hono' + * import { getHeaderLocale } from '@intlify/utils/hono' + * + * const app = new Hono() + * app.use('/', c => { + * const locale = getHeaderLocale(c) + * // ... + * return c.text(`accepted language: ${locale.toString()}`) + * }) + * ``` + * + * @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. + * + * @throws {RangeError} Throws the {@link RangeError} if `lang` option or header are not a well-formed BCP 47 language tag. + * + * @returns {Intl.Locale} 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`. + */ +export function getHeaderLocale( + context: Context, + { + lang = DEFAULT_LANG_TAG, + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions & { lang?: string } = {}, +): Intl.Locale { + return getLocaleWithGetter(() => + getHeaderLanguages(context, { name, parser })[0] || lang + ) +} + +/** + * get locale from cookie + * + * @example + * example for Hono: + * + * ```ts + * import { Hono } from 'hono' + * import { getCookieLocale } from '@intlify/utils/hono' + * + * const app = new Hono() + * app.use('/', c => { + * const locale = getCookieLocale(c) + * console.log(locale) // output `Intl.Locale` instance + * // ... + * }) + * ``` + * + * @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` + * + * @throws {RangeError} Throws a {@link RangeError} if `lang` option or cookie name value are not a well-formed BCP 47 language tag. + * + * @returns {Intl.Locale} The locale that resolved from cookie + */ +export function getCookieLocale( + context: Context, + { lang = DEFAULT_LANG_TAG, name = DEFAULT_COOKIE_NAME } = {}, +): Intl.Locale { + return getLocaleWithGetter(() => getCookie(context, name) || lang) +} + +/** + * set locale to the response `Set-Cookie` header. + * + * @example + * example for Hono: + * + * ```ts + * import { Hono } from 'hono' + * import { setCookieLocale } from '@intlify/utils/hono' + * + * const app = new Hono() + * app.use('/', c => { + * setCookieLocale(c, 'ja-JP') + * // ... + * }) + * ``` + * + * @param {Context} context A {@link Context | Hono} context + * @param {string | Intl.Locale} locale A locale value + * @param {CookieOptions} options A cookie options, `name` option is `i18n_locale` as default, and `path` option is `/` as default. + * + * @throws {SyntaxError} Throws the {@link SyntaxError} if `locale` is invalid. + */ +export function setCookieLocale( + context: Context, + locale: string | Intl.Locale, + options: CookieOptions = { name: DEFAULT_COOKIE_NAME }, +): void { + validateLocale(locale) + setCookie(context, options.name!, locale.toString(), options) +} + +/** + * get the locale from the path + * + * @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 A path language parser, optional + * + * @throws {RangeError} Throws the {@link RangeError} if the language in the path, that is not a well-formed BCP 47 language tag. + * + * @returns {Intl.Locale} The locale that resolved from path + */ +export function getPathLocale( + context: Context, + { lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {}, +): Intl.Locale { + return _getPathLocale(new URL(context.req.url), { lang, parser }) +} + +/** + * get the locale from the query + * + * @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 + * + * @throws {RangeError} Throws the {@link RangeError} if the language in the query, that is not a well-formed BCP 47 language tag. + * + * @returns {Intl.Locale} The locale that resolved from query + */ +export function getQueryLocale( + context: Context, + { lang = DEFAULT_LANG_TAG, name = 'locale' }: QueryOptions = {}, +): Intl.Locale { + return _getQueryLocale(new URL(context.req.url), { lang, name }) +} diff --git a/tsconfig.json b/tsconfig.json index c320591..827b86f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,7 +39,9 @@ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ "types": [ - "vitest/importMeta" + "vitest/importMeta", + "@cloudflare/workers-types", + "vitest-environment-miniflare/globals" ], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */