From 5bb55ec8340cf6589b7e6db66de31d9f8407036e Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Tue, 17 Oct 2023 15:20:55 +0900 Subject: [PATCH] feat: add some locale getting APIs from path and query for platform --- src/h3.test.ts | 30 ++++++++++++++++ src/h3.ts | 48 ++++++++++++++++++++++++-- src/node.test.ts | 24 +++++++++++++ src/node.ts | 90 ++++++++++++++++++++++++++++++++++++++++++++++-- src/web.test.ts | 14 ++++++++ src/web.ts | 48 ++++++++++++++++++++++++-- 6 files changed, 248 insertions(+), 6 deletions(-) diff --git a/src/h3.test.ts b/src/h3.test.ts index 3b811b1..ad47743 100644 --- a/src/h3.test.ts +++ b/src/h3.test.ts @@ -7,6 +7,8 @@ import { getHeaderLanguages, getHeaderLocale, getHeaderLocales, + getPathLocale, + getQueryLocale, setCookieLocale, } from './h3.ts' import { parseAcceptLanguage } from './shared.ts' @@ -452,3 +454,31 @@ describe('setCookieLocale', () => { .toThrowError(/locale is invalid: j/) }) }) + +test('getPathLocale', async () => { + const app = createApp({ debug: false }) + const request = supertest(toNodeListener(app)) + + app.use( + '/', + eventHandler((event) => { + return { locale: getPathLocale(event).toString() } + }), + ) + const res = await request.get('/en/foo') + expect(res.body).toEqual({ locale: 'en' }) +}) + +test('getQueryLocale', async () => { + const app = createApp({ debug: false }) + const request = supertest(toNodeListener(app)) + + app.use( + '/', + eventHandler((event) => { + return { locale: getQueryLocale(event).toString() } + }), + ) + const res = await request.get('/?locale=ja') + expect(res.body).toEqual({ locale: 'ja' }) +}) diff --git a/src/h3.ts b/src/h3.ts index bb82837..e6727cd 100644 --- a/src/h3.ts +++ b/src/h3.ts @@ -6,14 +6,22 @@ import { import { getHeaderLanguagesWithGetter, getLocaleWithGetter, + getPathLocale as _getPathLocale, + getQueryLocale as _getQueryLocale, mapToLocaleFromLanguageTag, parseDefaultHeader, validateLocale, } from './http.ts' -import { getCookie, getHeaders, setCookie } from 'h3' +import { pathLanguageParser } from './shared.ts' +import { getCookie, getHeaders, getRequestURL, setCookie } from 'h3' import type { H3Event } from 'h3' -import type { CookieOptions, HeaderOptions } from './http.ts' +import type { + CookieOptions, + HeaderOptions, + PathOptions, + QueryOptions, +} from './http.ts' /** * get languages from header @@ -223,3 +231,39 @@ export function setCookieLocale( validateLocale(locale) setCookie(event, options.name!, locale.toString(), options) } + +/** + * get the locale from the path + * + * @param {H3Event} event the {@link H3Event | H3} event + * @param {PathOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {PathOptions['parser']} options.parser the 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( + event: H3Event, + { lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {}, +): Intl.Locale { + return _getPathLocale(getRequestURL(event), { lang, parser }) +} + +/** + * get the locale from the query + * + * @param {H3Event} event the {@link H3Event | H3} event + * @param {QueryOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {QueryOptions['name']} options.name the 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( + event: H3Event, + { lang = DEFAULT_LANG_TAG, name = 'locale' }: QueryOptions = {}, +): Intl.Locale { + return _getQueryLocale(getRequestURL(event), { lang, name }) +} diff --git a/src/node.test.ts b/src/node.test.ts index 6d131fd..f5abf67 100644 --- a/src/node.test.ts +++ b/src/node.test.ts @@ -6,6 +6,8 @@ import { getHeaderLanguages, getHeaderLocale, getHeaderLocales, + getPathLocale, + getQueryLocale, setCookieLocale, } from './node.ts' import { createServer, IncomingMessage, OutgoingMessage } from 'node:http' @@ -304,3 +306,25 @@ describe('setCookieLocale', () => { .toThrowError(/locale is invalid: j/) }) }) + +test('getPathLocale', async () => { + const server = createServer((req, res) => { + const locale = getPathLocale(req) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ locale: locale.toString() })) + }) + const request = supertest(server) + const result = await request.get('/en-US/foo') + expect(result.body).toEqual({ locale: 'en-US' }) +}) + +test('getQueryLocale', async () => { + const server = createServer((req, res) => { + const locale = getQueryLocale(req, { name: 'lang' }) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ locale: locale.toString() })) + }) + const request = supertest(server) + const result = await request.get('/?lang=ja') + expect(result.body).toEqual({ locale: 'ja' }) +}) diff --git a/src/node.ts b/src/node.ts index 361123c..6e40751 100644 --- a/src/node.ts +++ b/src/node.ts @@ -4,6 +4,8 @@ import { getExistCookies, getHeaderLanguagesWithGetter, getLocaleWithGetter, + getPathLocale as _getPathLocale, + getQueryLocale as _getQueryLocale, mapToLocaleFromLanguageTag, parseDefaultHeader, validateLocale, @@ -13,9 +15,14 @@ import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG, } from './constants.ts' -import { normalizeLanguageName } from './shared.ts' +import { normalizeLanguageName, pathLanguageParser } from './shared.ts' -import type { CookieOptions, HeaderOptions } from './http.ts' +import type { + CookieOptions, + HeaderOptions, + PathOptions, + QueryOptions, +} from './http.ts' /** * get languages from header @@ -245,6 +252,85 @@ export function setCookieLocale( response.setHeader('set-cookie', [...setCookies, target]) } +function getRequestProtocol( + request: IncomingMessage, + opts: { xForwardedProto?: boolean } = {}, +) { + if ( + opts.xForwardedProto !== false && + request.headers['x-forwarded-proto'] === 'https' + ) { + return 'https' + } + // deno-lint-ignore no-explicit-any + return (request.socket as any).encrypted ? 'https' : 'http' +} + +function getRequestHost( + request: IncomingMessage, + opts: { xForwardedHost?: boolean } = {}, +) { + if (opts.xForwardedHost) { + const xForwardedHost = request.headers['x-forwarded-host'] as string + if (xForwardedHost) { + return xForwardedHost + } + } + return request.headers.host || 'localhost' +} + +function getPath(request: IncomingMessage) { + // deno-lint-ignore no-explicit-any + const raw = (request as any).originalUrl || request.url || '/' + return raw.replace( + /^[/\\]+/g, + '/', + ) +} + +function getURL(request: IncomingMessage): URL { + const protocol = getRequestProtocol(request) + const host = getRequestHost(request) + const path = getPath(request) + return new URL(path, `${protocol}://${host}`) +} + +/** + * get the locale from the path + * + * @param {IncomingMessage} request the {@link IncomingMessage | request} + * @param {PathOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {PathOptions['parser']} options.parser the 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( + request: IncomingMessage, + { lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {}, +): Intl.Locale { + return _getPathLocale(getURL(request), { lang, parser }) +} + +/** + * get the locale from the query + * + * @param {IncomingMessage} request the {@link IncomingMessage | request} + * @param {QueryOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {QueryOptions['name']} options.name the 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( + request: IncomingMessage, + { lang = DEFAULT_LANG_TAG, name = 'locale' }: QueryOptions = {}, +): Intl.Locale { + return _getQueryLocale(getURL(request), { lang, name }) +} + let navigatorLanguages: string[] | undefined /** diff --git a/src/web.test.ts b/src/web.test.ts index c1e0280..36a605f 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -7,6 +7,8 @@ import { getHeaderLocales, getNavigatorLocale, getNavigatorLocales, + getPathLocale, + getQueryLocale, setCookieLocale, } from './web.ts' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' @@ -222,6 +224,18 @@ describe('setCookieLocale', () => { }) }) +test('getPathLocale', () => { + const mockRequest = new Request('https://locahost:3000/en/foo') + const locale = getPathLocale(mockRequest) + expect(locale.toString()).toEqual('en') +}) + +test('getQueryLocale', () => { + const mockRequest = new Request('https://locahost:3000/?intlify=ja') + const locale = getQueryLocale(mockRequest, { name: 'intlify' }) + expect(locale.toString()).toEqual('ja') +}) + describe('getNavigatorLocales', () => { test('basic', () => { vi.stubGlobal('navigator', { diff --git a/src/web.ts b/src/web.ts index 88af0a7..78083dd 100644 --- a/src/web.ts +++ b/src/web.ts @@ -3,17 +3,25 @@ import { getExistCookies, getHeaderLanguagesWithGetter, getLocaleWithGetter, + getPathLocale as _getPathLocale, + getQueryLocale as _getQueryLocale, mapToLocaleFromLanguageTag, parseDefaultHeader, validateLocale, } from './http.ts' +import { pathLanguageParser } from './shared.ts' import { ACCEPT_LANGUAGE_HEADER, DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG, } from './constants.ts' -import type { CookieOptions, HeaderOptions } from './http.ts' +import type { + CookieOptions, + HeaderOptions, + PathOptions, + QueryOptions, +} from './http.ts' /** * get languages from header @@ -186,7 +194,7 @@ export function getHeaderLocale( * }) * ``` * - * @param {IncomingMessage} request The {@link IncomingMessage | request} + * @param {Request} request The {@link Request | 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` * @@ -249,6 +257,42 @@ export function setCookieLocale( response.headers.set('set-cookie', [...setCookies, target].join('; ')) } +/** + * get the locale from the path + * + * @param {Request} request the {@link Request | request} + * @param {PathOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {PathOptions['parser']} options.parser the 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( + request: Request, + { lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {}, +): Intl.Locale { + return _getPathLocale(new URL(request.url), { lang, parser }) +} + +/** + * get the locale from the query + * + * @param {Request} request the {@link Request | request} + * @param {QueryOptions['lang']} options.lang the language tag, which is as default `'en-US'`. optional + * @param {QueryOptions['name']} options.name the 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( + request: Request, + { lang = DEFAULT_LANG_TAG, name = 'locale' }: QueryOptions = {}, +): Intl.Locale { + return _getQueryLocale(new URL(request.url), { lang, name }) +} + /** * get navigator languages *