diff --git a/deno.jsonc b/deno.jsonc index d8d8c4a..ba8878a 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -5,7 +5,7 @@ ], */ "lineWidth": 100, - "exclude": ["node_modules", "dist", "**/*.md"], + "exclude": ["node_modules", "dist", "**/*.md", "deno"], "semiColons": false, "singleQuote": true }, @@ -14,7 +14,7 @@ "include": [ ], */ - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "deno"] /* "rules": { "tags": ["recommended"], diff --git a/deno/LICENSE b/deno/LICENSE new file mode 100644 index 0000000..d14476d --- /dev/null +++ b/deno/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2023 kazuya kawaguchi + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/deno/README.md b/deno/README.md new file mode 100644 index 0000000..efefce4 --- /dev/null +++ b/deno/README.md @@ -0,0 +1,177 @@ +# @intilfy/utils + +[![npm version][npm-version-src]][npm-version-href] +[![npm downloads][npm-downloads-src]][npm-downloads-href] +[![CI][ci-src]][ci-href] + +Collection of i18n utilities + +## 🌟 Features + +βœ…οΈ  **Modern:** ES Modules first and respect Web Standard and ECMAScript +Internationalization APIs + +βœ…οΈ  **Compatible:** support CommonJS and various JS environments + +βœ…οΈοΈ  **Minimal:** Small and fully tree-shakable + +βœ…οΈοΈ  **Type Strong:** Written in TypeScript, with full JSdoc + +## πŸ’Ώ Installation + +### 🐒 Node.js + +```sh +# Using npm +npm install @intlify/utils + +# Using yarn +yarn add @intlify/utils + +# Using pnpm +pnpm add @intlify/utils +``` + +
+ Using Edge Releases + +If you are directly using `@intlify/utils` as a dependency: + +```json +{ + "dependencies": { + "@intlify/utils": "npm:@intlify/utils-edge@latest" + } +} +``` + +**Note:** Make sure to recreate lockfile and `node_modules` after reinstall to avoid hoisting issues. + +
+ +### πŸ¦• Deno + +You can install via `import`. + +in your code: + +```ts +/** + * you can install via other CDN URL such as skypack, + * or, you can also use import maps + * https://docs.deno.com/runtime/manual/basics/import_maps + */ +import { ... } from 'https://esm.sh/@intlify/utils' + +// something todo +// ... +``` + +
+ Using Edge Releases + +```ts +import { ... } from 'https://esm.sh/@intlify/utils-edge' + +// something todo +// ... +``` + +
+ +### πŸ₯Ÿ Bun + +```sh +bun install @intlify/utils +``` + +### 🌍 Browser + +in your HTML: + +```html + +``` + +## 🍭 Playground + +You can play the below examples: + +- 🐒 [Node.js](https://github.com/intlify/utils/tree/main/examples/node): + `npm run play:node` +- πŸ¦• [Deno](https://github.com/intlify/utils/tree/main/examples/deno): + `npm run play:deno` +- πŸ₯Ÿ [Bun](https://github.com/intlify/utils/tree/main/examples/bun): + `npm run play:bun` +- 🌍 [Browser](https://github.com/intlify/utils/tree/main/examples/browser): + `npm run play:browser` + +## πŸ”¨ Utilities + +### Common + +- `isLocale` +- `toLocale` +- `parseAcceptLanguage` +- `validateLangTag` +- `normalizeLanguageName` + +You can do `import { ... } from '@intlify/utils'` the above utilities + +### Navigator + +- `getNavigatorLocales` +- `getNavigatorLocale` + +You can do `import { ... } from '@intlify/utils'` the above utilities + +> ⚠NOTE: for Node.js You need to do `import { ... } from '@intlify/utils/node'` + +### HTTP + +- `getHeaderLanguages` +- `getHeaderLanguage` +- `getHeaderLocales` +- `getHeaderLocale` +- `getCookieLocale` +- `setCookieLocale` +- `getPathLocale` +- `getQueryLocale` + +The about utilies functions accpet Web APIs such as [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) that is supported by JS environments (such as Deno, Bun, and Browser) + +#### Specialized environments + +If you will use Node.js and H3, You can do `import { ... } from '@intlify/utils/{ENV}'` the above utilities. + +The namespace `{ENV}` is one of the following: + +- `node`: accpet `IncomingMessage` and `Outgoing` by Node.js [http](https://nodejs.org/api/http.html) module +- `h3`: accept `H3Event` by HTTP framework [h3](https://github.com/unjs/h3) +- `hono`: accept `Context` by edge-side web framework [hono](https://github.com/honojs/hono) + +## πŸ™Œ Contributing guidelines + +If you are interested in contributing to `@intlify/utils`, I highly recommend checking out [the contributing guidelines](/CONTRIBUTING.md) here. You'll find all the relevant information such as [how to make a PR](/CONTRIBUTING.md#pull-request-guidelines), [how to setup development](/CONTRIBUTING.md#development-setup)) etc., there. + +## ©️ License + +[MIT](http://opensource.org/licenses/MIT) + + + +[npm-version-src]: https://img.shields.io/npm/v/@intlify/utils?style=flat&colorA=18181B&colorB=FFAD33 +[npm-version-href]: https://npmjs.com/package/@intlify/utils +[npm-downloads-src]: https://img.shields.io/npm/dm/@intlify/utils?style=flat&colorA=18181B&colorB=FFAD33 +[npm-downloads-href]: https://npmjs.com/package/@intlify/utils +[ci-src]: https://github.com/intlify/utils/actions/workflows/ci.yml/badge.svg +[ci-href]: https://github.com/intlify/utils/actions/workflows/ci.yml diff --git a/deno/constants.ts b/deno/constants.ts new file mode 100644 index 0000000..4e817d2 --- /dev/null +++ b/deno/constants.ts @@ -0,0 +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/deno/http.ts b/deno/http.ts new file mode 100644 index 0000000..5a1b8d6 --- /dev/null +++ b/deno/http.ts @@ -0,0 +1,265 @@ +import { + isLocale, + isURL, + isURLSearchParams, + parseAcceptLanguage, + pathLanguageParser, + toLocale, + validateLangTag, +} from './shared.ts' +import { ACCEPT_LANGUAGE_HEADER, DEFAULT_LANG_TAG } from './constants.ts' + +import type { PathLanguageParser } from './shared.ts' +// import type { CookieSerializeOptions } from 'cookie-es' +// NOTE: This is a copy of the type definition from `cookie-es` package, we want to avoid building error for this type definition ... + +interface CookieSerializeOptions { + /** + * Specifies the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.3|Domain Set-Cookie attribute}. By default, no + * domain is set, and most clients will consider the cookie to apply to only + * the current domain. + */ + domain?: string | undefined + /** + * Specifies a function that will be used to encode a cookie's value. Since + * value of a cookie has a limited character set (and must be a simple + * string), this function can be used to encode a value into a string suited + * for a cookie's value. + * + * The default function is the global `encodeURIComponent`, which will + * encode a JavaScript string into UTF-8 byte sequences and then URL-encode + * any that fall outside of the cookie range. + */ + encode?(value: string): string + /** + * Specifies the `Date` object to be the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.1|`Expires` `Set-Cookie` attribute}. By default, + * no expiration is set, and most clients will consider this a "non-persistent cookie" and will delete + * it on a condition like exiting a web browser application. + * + * *Note* the {@link https://tools.ietf.org/html/rfc6265#section-5.3|cookie storage model specification} + * states that if both `expires` and `maxAge` are set, then `maxAge` takes precedence, but it is + * possible not all clients by obey this, so if both are set, they should + * point to the same date and time. + */ + expires?: Date | undefined + /** + * Specifies the boolean value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.6|`HttpOnly` `Set-Cookie` attribute}. + * When truthy, the `HttpOnly` attribute is set, otherwise it is not. By + * default, the `HttpOnly` attribute is not set. + * + * *Note* be careful when setting this to true, as compliant clients will + * not allow client-side JavaScript to see the cookie in `document.cookie`. + */ + httpOnly?: boolean | undefined + /** + * Specifies the number (in seconds) to be the value for the `Max-Age` + * `Set-Cookie` attribute. The given number will be converted to an integer + * by rounding down. By default, no maximum age is set. + * + * *Note* the {@link https://tools.ietf.org/html/rfc6265#section-5.3|cookie storage model specification} + * states that if both `expires` and `maxAge` are set, then `maxAge` takes precedence, but it is + * possible not all clients by obey this, so if both are set, they should + * point to the same date and time. + */ + maxAge?: number | undefined + /** + * Specifies the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.4|`Path` `Set-Cookie` attribute}. + * By default, the path is considered the "default path". + */ + path?: string | undefined + /** + * Specifies the `string` to be the value for the [`Priority` `Set-Cookie` attribute][rfc-west-cookie-priority-00-4.1]. + * + * - `'low'` will set the `Priority` attribute to `Low`. + * - `'medium'` will set the `Priority` attribute to `Medium`, the default priority when not set. + * - `'high'` will set the `Priority` attribute to `High`. + * + * More information about the different priority levels can be found in + * [the specification][rfc-west-cookie-priority-00-4.1]. + * + * **note** This is an attribute that has not yet been fully standardized, and may change in the future. + * This also means many clients may ignore this attribute until they understand it. + */ + priority?: 'low' | 'medium' | 'high' | undefined + /** + * Specifies the boolean or string to be the value for the {@link https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7|`SameSite` `Set-Cookie` attribute}. + * + * - `true` will set the `SameSite` attribute to `Strict` for strict same + * site enforcement. + * - `false` will not set the `SameSite` attribute. + * - `'lax'` will set the `SameSite` attribute to Lax for lax same site + * enforcement. + * - `'strict'` will set the `SameSite` attribute to Strict for strict same + * site enforcement. + * - `'none'` will set the SameSite attribute to None for an explicit + * cross-site cookie. + * + * More information about the different enforcement levels can be found in {@link https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7|the specification}. + * + * *note* This is an attribute that has not yet been fully standardized, and may change in the future. This also means many clients may ignore this attribute until they understand it. + */ + sameSite?: true | false | 'lax' | 'strict' | 'none' | undefined + /** + * Specifies the boolean value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.5|`Secure` `Set-Cookie` attribute}. When truthy, the + * `Secure` attribute is set, otherwise it is not. By default, the `Secure` attribute is not set. + * + * *Note* be careful when setting this to `true`, as compliant clients will + * not send the cookie back to the server in the future if the browser does + * not have an HTTPS connection. + */ + secure?: boolean | undefined +} + +export type CookieOptions = CookieSerializeOptions & { name?: string } + +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 langString = getter() + return langString + ? name === ACCEPT_LANGUAGE_HEADER + ? parser === parseDefaultHeader ? parseAcceptLanguage(langString) : parser(langString) + : parser(langString) + : [] +} + +export function getLocaleWithGetter(getter: () => string): Intl.Locale { + return toLocale(getter()) +} + +export function validateLocale(locale: string | Intl.Locale): void { + if ( + !(isLocale(locale) || + typeof locale === 'string' && validateLangTag(locale)) + ) { + throw new SyntaxError(`locale is invalid: ${locale.toString()}`) + } +} + +export function mapToLocaleFromLanguageTag( + // deno-lint-ignore no-explicit-any + getter: (...args: any[]) => string[], + ...args: unknown[] +): Intl.Locale[] { + return (Reflect.apply(getter, null, args) as string[]).map((lang) => + getLocaleWithGetter(() => lang) + ) +} + +export function getExistCookies( + name: string, + getter: () => unknown, +) { + let setCookies = getter() + if (!Array.isArray(setCookies)) { + setCookies = [setCookies] + } + setCookies = (setCookies as string[]).filter((cookieValue: string) => + cookieValue && !cookieValue.startsWith(name + '=') + ) + return setCookies as string[] +} + +export type PathOptions = { + lang?: string + parser?: PathLanguageParser +} + +/** + * get the language from the path + * + * @param {string | URL} path the target path + * @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 + * + * @returns {string} the language that is parsed by the path language parser, if the language is not detected, return a `options.lang` value + */ +export function getPathLanguage( + path: string | URL, + { lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {}, +): string { + return (parser || pathLanguageParser)(path) || lang +} + +/** + * get the locale from the path + * + * @param {string | URL} path the target path + * @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( + path: string | URL, + { lang = DEFAULT_LANG_TAG, parser = pathLanguageParser }: PathOptions = {}, +): Intl.Locale { + return new Intl.Locale(getPathLanguage(path, { lang, parser })) +} + +function getURLSearchParams( + input: string | URL | URLSearchParams, +): URLSearchParams { + if (isURLSearchParams(input)) { + return input + } else if (isURL(input)) { + return input.searchParams + } else { + return new URLSearchParams(input) + } +} + +export type QueryOptions = { + lang?: string + name?: string +} + +/** + * get the language from the query + * + * @param {string | URL | URLSearchParams} query the target query + * @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 `'lang'`. optional + * + * @returns {string} the language from query, if the language is not detected, return an `options.lang` option string. + */ +export function getQueryLanguage( + query: string | URL | URLSearchParams, + { lang = DEFAULT_LANG_TAG, name = 'lang' }: QueryOptions = {}, +): string { + const queryParams = getURLSearchParams(query) + return queryParams.get(name) || lang +} + +/** + * get the locale from the query + * + * @param {string | URL | URLSearchParams} query the target query + * @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( + query: string | URL | URLSearchParams, + { lang = DEFAULT_LANG_TAG, name = 'locale' }: QueryOptions = {}, +): Intl.Locale { + return new Intl.Locale(getQueryLanguage(query, { lang, name })) +} diff --git a/deno/index.ts b/deno/index.ts new file mode 100644 index 0000000..b82968f --- /dev/null +++ b/deno/index.ts @@ -0,0 +1,9 @@ +export { + createPathIndexLanguageParser, + isLocale, + normalizeLanguageName, + parseAcceptLanguage, + registerPathLanguageParser, + validateLangTag, +} from './shared.ts' +export * from './web.ts' diff --git a/deno/locale.ts b/deno/locale.ts new file mode 100644 index 0000000..e7ff923 --- /dev/null +++ b/deno/locale.ts @@ -0,0 +1,880 @@ +/** + * NOTE: + * This test is work in pregoress ... + * We might remove this test file in the future, + * when we will find out that cannot support locale validation + */ + +import type { + All, + Concat, + Filter, + First, + Includes, + IsNever, + Join, + Length, + Push, + Shift, + Split, + StringToArray, + TupleToUnion, + UnionToTuple, +} from './types.ts' + +export interface UnicodeLocaleId { + lang: UnicodeLanguageId + extensions: Array< + UnicodeExtension | TransformedExtension | PuExtension | OtherExtension + > +} + +export type KV = [string, string] | [string] + +export interface Extension { + type: string +} + +export interface UnicodeExtension extends Extension { + type: 'u' + keywords: KV[] + attributes: string[] +} + +export interface TransformedExtension extends Extension { + type: 't' + fields: KV[] + lang?: UnicodeLanguageId +} +export interface PuExtension extends Extension { + type: 'x' + value: string +} + +export interface OtherExtension extends Extension { + type: + | 'a' + | 'b' + | 'c' + | 'd' + | 'e' + | 'f' + | 'g' + | 'h' + | 'i' + | 'j' + | 'k' + | 'l' + | 'm' + | 'n' + | 'o' + | 'p' + | 'q' + | 'r' + | 's' + | 'v' + | 'w' + | 'y' + | 'z' + value: string +} + +export interface UnicodeLanguageId { + lang: string + script?: string + region?: string + variants: string[] +} + +type Alphabets = + | 'a' + | 'b' + | 'c' + | 'd' + | 'e' + | 'f' + | 'g' + | 'h' + | 'i' + | 'j' + | 'k' + | 'l' + | 'm' + | 'n' + | 'o' + | 'p' + | 'q' + | 'r' + | 's' + | 't' + | 'u' + | 'v' + | 'w' + | 'x' + | 'y' + | 'z' +type Digits = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' +type Alpha = TupleToUnion< + Concat, UnionToTuple>> +> +type AlphaNumber = TupleToUnion< + Concat, UnionToTuple> +> + +type OtherExtensions = TupleToUnion< + Concat, UnionToTuple> +> + +export type CheckRange< + T extends unknown[], + Indexes extends number[], +> = Includes> extends true ? true : false + +// deno-fmt-ignore +export type ValidCharacters< + T extends unknown[], + UnionChars = Alphabets, // default alphabets + Target = First, + Rest extends unknown[] = Shift, +> = IsNever extends false + ? [Includes, Target>, ...ValidCharacters] + : [] + +export const localeErrors = /* @__PURE__ */ { + 1: 'missing unicode language subtag', + 2: 'malformed unicode language subtag', + 3: 'requires 2-3 or 5-8 alphabet lower characters', + 4: 'malformed unicode script subtag', + 5: 'unicode script subtag requires 4 alphabet lower characters', + 6: 'malformed unicode region subtag', + 7: 'unicode region subtag requires 2 alphabet lower characters or 3 digits', + 8: 'duplicate unicode variant subtag', + 9: 'malformed unicode extension', + 10: 'missing tvalue for tkey', + 11: 'malformed transformed extension', + 12: 'malformed private use extension', + 13: 'There can only be 1 -u- extension', + 14: 'There can only be 1 -t- extension', + 15: 'There can only be 1 -x- extension', + 16: 'Malformed extension type', + 17: 'There can only be 1 -${type}- extension', + 1024: 'Unexpected error', +} as const + +/** + * parse unicode language id + * https://unicode.org/reports/tr35/#unicode_language_id + */ +export type ParseUnicodeLanguageId< + Chunks extends string | unknown[], + ErrorMsg extends Record = typeof localeErrors, + Chars extends unknown[] = Chunks extends string ? Split : Chunks, + Lang extends [string, number, unknown[]] = ParseLangSubtag< + First, + Chars + >, + Rest1 extends unknown[] = Lang[2], + Script extends [string, number, unknown[]] = ParseScriptSubtag< + First, + Rest1 + >, + Rest2 extends unknown[] = Script[2], + Region extends [string, number, unknown[]] = ParseRegionSubtag< + First, + Rest2 + >, + Rest3 extends unknown[] = Region[2], + Variants extends [string[], number | never, unknown[]] = ParseVariantsSubtag< + Rest3 + >, + Errors extends unknown[] = Filter<[ + ErrorMsg[Lang[1]], + ErrorMsg[Script[1]], + ErrorMsg[Region[1]], + ErrorMsg[Variants[1]], + ], never>, + RestChars = Variants[2], +> = [ + { + lang: Lang[0] + script: Script[0] + region: Region[0] + variants: Variants[0] + }, + Length extends 0 ? never : Errors, + RestChars, +] + +/** + * parse unicode language subtag + * https://unicode.org/reports/tr35/#unicode_language_subtag + */ +// deno-fmt-ignore +export type ParseLangSubtag< + Chunk, + RestChunks extends unknown[] = [], + Result extends [string, number, unknown[]] = IsNever extends true + ? [never, 1, RestChunks] // missing + : Chunk extends '' + ? [never, 1, RestChunks] // missing + : Chunk extends 'root' + ? ['root', never, RestChunks] // 'root' is special case + : Chunk extends string + ? ParseUnicodeLanguageSubtag + : never // unexpected +> = Result + +/** + * parse unicode language subtag (EBNF: = alpha{2,3} | alpha{5,8};) + * https://unicode.org/reports/tr35/#unicode_language_subtag + */ +// TODO: Check if the language subtag is in CLDR +// deno-fmt-ignore +export type ParseUnicodeLanguageSubtag< + Chunk extends string, + RestChunks extends unknown[] = [], + Chars extends unknown[] = StringToArray, +> = CheckRange extends true + ? Includes, false> extends true // check if all chars are alphabets + ? [never, 2, RestChunks] // malformed + : [Chunk, never, Shift] + : [never, 3, RestChunks] // require characters length + +/** + * parse unicode script subtag + * https://unicode.org/reports/tr35/#unicode_script_subtag + */ +// deno-fmt-ignore +export type ParseScriptSubtag< + Chunk, + RestChunks extends unknown[] = [], + Result extends [string, number, unknown[]] = IsNever extends true + ? [never, never, RestChunks] // missing + : Chunk extends '' + ? [never, never, RestChunks] // missing + : Chunk extends string + ? ParseUnicodeScriptSubtag + : never // unexpected +> = Result + +/** + * paser unicode script subtag (EBNF: = alpha{4};) + * https://unicode.org/reports/tr35/#unicode_script_subtag + */ +// TODO: Check if the script subtag is in CLDR +// deno-fmt-ignore +export type ParseUnicodeScriptSubtag< + Chunk extends string, + RestChunks extends unknown[] = [], + Chars extends unknown[] = StringToArray, +> = CheckRange extends true + ? Includes, false> extends true // check if all chars are alphabets + ? [never, 4, RestChunks] // malformed + : [Chunk, never, Shift] + : Length extends 0 + ? [never, 5, RestChunks] // require characters length + : [never, never, RestChunks] // through + +/** + * parse unicode region subtag + * https://unicode.org/reports/tr35/#unicode_region_subtag + */ +// deno-fmt-ignore +export type ParseRegionSubtag< + Chunk, + RestChunks extends unknown[] = [], + Result extends [string, number, unknown[]] = IsNever extends true + ? [never, never, RestChunks] // missing + : Chunk extends '' + ? [never, never, RestChunks] // missing + : Chunk extends string + ? ParseUnicodeRegionSubtag + : never, // unexpected +> = Result + +/** + * parse unicode region subtag (= (alpha{2} | digit{3}) ;) + * https://unicode.org/reports/tr35/#unicode_region_subtag + */ +// TODO: Check if the region subtag is in CLDR +// deno-fmt-ignore +export type ParseUnicodeRegionSubtag< + Chunk extends string, + RestChunks extends unknown[] = [], + Chars extends unknown[] = StringToArray, + HasAlphabetsOnly = All, true>, + HasDigitsOnly = All, true>, +> = CheckRange extends true + ? Length extends 2 + ? HasAlphabetsOnly extends true + ? [Chunk, never, Shift] + : HasDigitsOnly extends true + ? ThroughErrorWithChunks // require characters length + : ThroughErrorWithChunks // malformed + : Length extends 3 + ? HasDigitsOnly extends true + ? [Chunk, never, Shift] + : HasAlphabetsOnly extends true + ? ThroughErrorWithChunks // require characters length + : ThroughErrorWithChunks // malformed + : [never, 7, RestChunks] // require characters length + : ThroughErrorWithChunks // require characters length + +type ThroughErrorWithChunks = Length extends 0 ? Result + : [never, never, Chunks] + +/** + * parse unicode variant subtag + * https://unicode.org/reports/tr35/#unicode_variant_subtag + */ +export type ParseVariantsSubtag< + Chunks extends unknown[], + Result extends [string[], number | never, unknown[]] = _ParseVariantsSubtag< + Chunks + >, +> = Result + +// deno-fmt-ignore +type _ParseVariantsSubtag< + Chunks extends unknown[] = [], + Accumrator extends [string[], number | never] = [[], never], + HasVariants = Length extends 0 ? false : true, + Target = First, + Variant extends string = HasVariants extends true + ? Target extends string ? Target : never + : never, + VariantSubTag = ParseUnicodeVariantsSubtag extends [infer Tag, never] ? Tag : never, + Rest extends unknown[] = Shift, + Duplicate = IsNever extends false + ? Includes extends true ? true : false + : false, + VariantStr extends string = VariantSubTag extends string ? VariantSubTag : never, + RestChunks = IsNever extends true ? Chunks : Rest, + > = IsNever extends false + ? [[...Accumrator[0]], Accumrator[1], RestChunks] + : Duplicate extends true + ? [[...Accumrator[0]], 8, RestChunks] + : IsNever extends true + ? [[...Accumrator[0]], never, RestChunks] + : _ParseVariantsSubtag], Accumrator[1]]> + +/** + * parse unicode variant subtag (= (alphanum{5,8} | digit alphanum{3}) ;) + * https://unicode.org/reports/tr35/#unicode_variant_subtag + */ +// deno-fmt-ignore +type ParseUnicodeVariantsSubtag< + Chunk extends string, + Chars extends unknown[] = StringToArray, + FirstChar = First, + RemainChars extends unknown[]= Shift, +> = Length extends 3 + ? All, true> extends true // check digit at first char + ? All, true> extends true// check alphanum at remain chars + ? [Chunk, never] + : [never, never] // ignore + : [never, never] // ignore + : Length extends 4 + ? [never, never] // ignore + : CheckRange extends true + ? All, true> extends true// capture alphanum + ? [Chunk, never] + : [never, never] // ignore + : [never, never] // ignore + +// TODO: +type ParseUnicodeLocaleId = true + +// TODO: +/** + * parse unicode locale extensions + * https://unicode.org/reports/tr35/#extensions + * + * = unicode_locale_extensions + * | transformed_extensions + * | other_extensions ; + */ +type ParseUnicodeExtensions< + Chunks extends unknown[], + Extensions extends UnicodeLocaleId['extensions'] = [], + ResultExtensions extends [Omit, number, unknown[]] = + _ParseUnicodeExtensions< + Chunks, + Extensions + >, + Result extends [Omit, number, unknown[]] = Length extends 0 + ? [{ extensions: [] }, never, Chunks] + : IsNever extends false ? [{ extensions: [] }, ResultExtensions[1], Chunks] + : [ResultExtensions[0], never, ResultExtensions[2]], +> = Result + +// type p1 = ParseUnicodeExtensions<['x', '1234']> + +type _ParseUnicodeExtensions< + Chunks extends unknown[], + Extensions extends UnicodeLocaleId['extensions'] = [], + ExistPuExtension extends PuExtension = never, + ExistOtherExtensions extends unknown[] = [], + Chunk = First, + Type extends string = Chunk extends string ? Chunk : never, + RestChunks extends unknown[] = Shift, + // UnicodeExtension = Includes<['u', 'U'], Type> extends true, + // ? ParseUnicodeExtension + // : never, + // TransformedExtension = Includes<['t', 'T'], Type> extends true + // ? ParseTransformedExtension + // : never, + + // parse for PuExtension + ResultParsePu extends [PuExtension, number, unknown[]] = _ParseUnicodeExtensionsPu< + RestChunks, + Type, + ExistPuExtension + >, + _ExtensionsPu extends UnicodeLocaleId['extensions'] = Push< + Extensions, + ResultParsePu[0] + >, /*[ + ...(ResultParsePu[1] extends number ? Extensions + : Push), + ], + */ + // parse for OtherExtension + /* + ResultParseOther extends [OtherExtension, number, unknown[]] = + _ParseUnicodeExtensionsOther< + [...ResultParsePu[2]], // rest chunks + Type, + ExistOtherExtensions + >, + _ExtensionsOther extends UnicodeLocaleId['extensions'] = + ResultParseOther[1] extends number ? [..._ExtensionsPu] + : [...Push<_ExtensionsPu, ResultParseOther[0]>], + NextExistOtherExtensions extends unknown[] = ResultParseOther[0] extends + OtherExtension ? [...Push] + : [...ExistOtherExtensions], + */ + // check error + Error extends number = ResultParsePu[1] extends number ? ResultParsePu[1] + // : ResultParseOther[1] extends number ? ResultParseOther[1] + : never, + // tweak shared chunks for next parsing + NextChunks extends unknown[] = [...ResultParsePu[2]], // [...ResultParseOther[2]], + // tweak extensions + NextExtensions extends UnicodeLocaleId['extensions'] = _ExtensionsPu, // _ExtensionsOther, + NextExistPuExtension extends PuExtension = ResultParsePu[0], +> = IsNever extends false ? [never, Error, Chunks] + : Length extends 0 ? [{ extensions: Extensions }, never, Chunks] + : Length extends 0 ? [{ extensions: NextExtensions }, never, NextChunks] + : _ParseUnicodeExtensions< + NextChunks, + NextExtensions, + NextExistPuExtension + > // ResultParsePu[0] +// NextExistOtherExtensions + +// type pp1 = _ParseUnicodeExtensions<['x', '1234']> + +type _ParseUnicodeExtensionsPu< + Chunks extends unknown[], + Type extends string, + ExistPuExtension extends PuExtension = never, + ResultParsePuExtension extends unknown[] = CheckExtensionType extends true + ? ParsePuExtension<[...Chunks]> + : never, + _PuExtension extends PuExtension = ResultParsePuExtension[0] extends PuExtension + ? ResultParsePuExtension[0] + : never, + RestChunks extends unknown[] = ResultParsePuExtension[2] extends unknown[] + ? ResultParsePuExtension[2] + : Chunks, + Error extends number = IsNever extends false ? 14 + : ResultParsePuExtension[1] extends number ? ResultParsePuExtension[1] + : never, + Result extends [PuExtension, number, unknown[]] = [ + IsNever extends true ? _PuExtension : never, + Error, + RestChunks, + ], +> = Result + +// type _pu0 = _ParseUnicodeExtensionsPu< +// ['1234'], +// 'x' +// > +// type _pu1 = _ParseUnicodeExtensionsPu< +// ['1234'], +// 'x', +// { type: 'x'; value: '111' } +// > + +type _ParseUnicodeExtensionsOther< + Chunks extends unknown[], + Type extends string, + ExistOtherExtensions extends unknown[] = never, + MalformedError extends number = Includes, Type> extends false ? 16 + : never, + Error extends number = MalformedError extends number ? MalformedError + : Includes extends true ? 17 + : never, + ResultParseOtherExtension extends [string, unknown[]] = IsNever extends true + ? ParseOtherExtension<[...Chunks]> + : [never, Chunks], + RestChunks extends unknown[] = ResultParseOtherExtension[1] extends unknown[] + ? ResultParseOtherExtension[1] + : Chunks, + Result extends [OtherExtension, number, unknown[]] = [ + ResultParseOtherExtension[0] extends string ? { type: 'a'; value: ResultParseOtherExtension[0] } + : never, + Error, + RestChunks, + ], +> = Result + +// type _ou1 = _ParseUnicodeExtensionsOther<['abc'], 'z'> + +type CheckExtensionType< + Type extends string, + Ext extends string[], + Chars extends unknown[] = StringToArray, +> = Length extends 1 ? Includes extends true ? true + : false + : false + +/** + * parse unicode locale extension + * https://unicode.org/reports/tr35/#unicode_locale_extensions + * + * = ((sep keyword)+ | (sep attribute)+ (sep keyword)*) ; + */ +// deno-fmt-ignore +export type ParseUnicodeExtension< + Chunks extends unknown[], + Sep extends string = '-', + ResultFirstKeyword extends unknown[] = CollectFirstKeywords, + FirstKeywolds extends unknown[] = ResultFirstKeyword[0] extends unknown[] ? ResultFirstKeyword[0] : never, + FirstRestChunks extends unknown[] = ResultFirstKeyword[1] extends unknown[] ? ResultFirstKeyword[1] : Chunks, + ResultAttribute extends [unknown[], unknown[]] = ParseAttribute, + Attributes extends unknown[] = ResultAttribute[0], + RestAttributeChunks extends unknown[] = ResultAttribute[1] extends unknown[] ? ResultAttribute[1] : never, + ResultKeyword extends unknown[] = ParseKeyword, + _Keywords extends unknown[] = Push<[], ResultKeyword[0]>, + Keywords extends unknown[] = Push<_Keywords, ResultKeyword[1]>, + RestChunks extends unknown[] = ResultKeyword[2] extends unknown[] ? ResultKeyword[2] : never, +> = Length extends 0 + ? IsNever extends false + ? Length extends 0 + ? Length extends 0 + ? [never, 9] // malformed + : [{ type: 'u'; keywords: Keywords; attributes: Attributes }, never, RestChunks] + : [{ type: 'u'; keywords: Keywords; attributes: Attributes }, never, RestChunks] + : [{ type: 'u'; keywords: []; attributes: Attributes }, never, ResultKeyword[1]] + : [{ type: 'u'; keywords: FirstKeywolds; attributes: [] }, never, FirstRestChunks] + +type t0 = ParseUnicodeExtension<['c']> +type t1 = ParseUnicodeExtension<['co', 'standard']> +type t2 = ParseUnicodeExtension<['foo', 'bar', 'co', 'standard']> + +// deno-fmt-ignore +export type CollectFirstKeywords< + Chunks extends unknown[], + Sep extends string = '-', + Keywords extends unknown[] = [], + ResultKeyword extends unknown[] = ParseKeyword, + RestChunks extends unknown[] = ResultKeyword[2] extends unknown[] ? ResultKeyword[2] : never, + _Keywords1 extends unknown[] = Push<[], ResultKeyword[0]>, + _Keywords2 extends unknown[] = Push<_Keywords1, ResultKeyword[1]>, +> = Length extends 0 + ? Length extends 0 + ? [never, Chunks] + : [Keywords, Chunks] + : IsNever extends true + ? [Keywords, Chunks] + : CollectFirstKeywords + +// type c0 = CollectFirstKeywords<['c']> +// type c1 = CollectFirstKeywords<['co', 'standard', 'x']> +// type c2 = CollectFirstKeywords<['co', 'standard', 'r111', 'u']> +// type c4 = CollectFirstKeywords<['foo', 'bar', 'co', 'standard']> + +/** + * parse attribute at unicode locale extension generally + * `attribute` at https://unicode.org/reports/tr35/#Unicode_locale_identifier + * + * (= alphanum{3,8} ;) + */ +// deno-fmt-ignore +export type ParseAttribute< + Chunks extends unknown[], + Attributes extends unknown[] = [], + RestChunks extends unknown[] = Shift, + Chunk extends string = Chunks[0] extends string ? Chunks[0] : never, + ChunkChars extends unknown[] = StringToArray, +> = Length extends 0 + ? [Attributes, RestChunks] + : Chunk extends string + ? CheckRange extends true // check attribute length + ? All, true> extends true // check attribute characters + ? ParseAttribute< + RestChunks, + [...Push] + > + : [Attributes, Chunks] + : [Attributes, Chunks] + : [Attributes, Chunks] + +// type pa1 = ParseAttribute<['foo', 'bar', 'co', 'standard']> +// type pa2 = ParseAttribute<['foo', 'bar']> +// type pa3 = ParseAttribute<['co', 'standard']> +// type pa4 = ParseAttribute<['c']> + +/** + * parse keyword at unicode locale extension generally + * `keyword` at https://unicode.org/reports/tr35/#Unicode_locale_identifier + * + * (= key (sep type)? ;) + */ +// deno-fmt-ignore +export type ParseKeyword< + Chunks extends unknown[], + Sep extends string = '-', + Key = ParseKeywordKey, + Rest extends unknown[] = Shift, + ResultValue extends unknown[] = ParseKeywordValue, +> = IsNever extends true + ? [never, Chunks] + : [Key, ResultValue[0], ResultValue[1]] + +// type k = ParseKeyword<['']> +// type k0 = ParseKeyword<['c']> +// type k1 = ParseKeyword<['co', 'standard', 'x']> +// type k2 = ParseKeyword<['co', 'standard', 'r111', 'u']> +// type k3 = ParseKeyword<['co', 'standard']> + +/** + * parse keyword key at unicode locale extension generally + * `key` at https://unicode.org/reports/tr35/#Unicode_locale_identifier + * + * (= alphanum alpha ;) + */ +// deno-fmt-ignore +type ParseKeywordKey< + Chunks extends unknown[], + _Chunk = First, + Chunk extends string = _Chunk extends string ? _Chunk : never, + ChunkChars extends unknown[] = StringToArray, + Key1 extends string = ChunkChars[0] extends string ? ChunkChars[0] : never, + Key2 extends string = ChunkChars[1] extends string ? ChunkChars[1] : never, +> = Chunk extends string + ? Length extends 2 + ? All, AlphaNumber>, true> extends true + ? All, Alpha>, true> extends true + ? Chunk + : never + : never + : never + : never + +// deno-fmt-ignore +type ParseKeywordValue< + Chunks extends unknown[], + Sep extends string = '-', + ResultKeywordType extends [unknown[], unknown[]] = ParseKeywordType, +> = Length extends 0 + ? ['', ResultKeywordType[1]] + : [Join, ResultKeywordType[1]] + +/** + * parse type on keyword at unicode locale extension generally + * `type` at https://unicode.org/reports/tr35/#Unicode_locale_identifier + * + * (= alphanum{3,8} (sep alphanum{3,8})* ;) + */ +// deno-fmt-ignore +type ParseKeywordType< + Chunks extends unknown[], + Types extends unknown[] = [], + Chunk extends string = Chunks[0] extends string ? Chunks[0] : never, + ChunkChars extends unknown[] = StringToArray, + ExitReturn = [Types, Chunks], +> = Length extends 0 + ? ExitReturn + : Chunk extends string + ? CheckRange extends true // check type length + ? All, true> extends true // check type characters + ? ParseKeywordType, [...Push]> + : ExitReturn + : ExitReturn + : ExitReturn + +/** + * parse transformed extension + * https://unicode.org/reports/tr35/#transformed_extensions + * + * = sep [tT] ((sep tlang (sep tfield)*) | (sep tfield)+) ; + */ +// deno-fmt-ignore +export type ParseTransformedExtension< + Chunks extends unknown[], + /* Excessive stack depth comparing types ... + ResultLangId extends [UnicodeLanguageId, number, unknown[]] = + ParseUnicodeLanguageId, + */ + ResultLangId extends unknown[] = ParseUnicodeLanguageId< + Chunks + >, + LangParseError extends number = ResultLangId[1] extends number ? ResultLangId[1] : never, + RestChunks extends unknown[] = ResultLangId[2] extends unknown[] + ? ResultLangId[2] + : never, + ResultFields extends unknown[] = ParseTransformedExtensionFields, + Fields extends unknown[] = ResultFields[0] extends unknown[] ? ResultFields[0] : never, + TransformedParseError = ResultFields[1] extends number ? ResultFields[1] : never, + NextChunks extends unknown[] = IsNever extends false + ? Chunks + : IsNever extends false + ? RestChunks + : ResultFields[2] extends unknown[] + ? ResultFields[2] + : Chunks +> = IsNever extends false + ? [{ type: 't', lang: ResultLangId[0], fields: ResultFields[0] }, LangParseError, NextChunks] + : IsNever extends false + ? [{ type: 't', lang: ResultLangId[0], fields: ResultFields[0] }, TransformedParseError, NextChunks] + : Length extends 0 + ? [never, 11, Chunks] // malformed + : [{ type: 't', lang: ResultLangId[0], fields: ResultFields[0] }, never, NextChunks] + +// type pt1 = ParseTransformedExtension< +// ['en', 'Kana', 'US', 'jauer', 'h0', 'hybrid'] +// > +// type ll1 = ParseUnicodeLanguageId< +// ['en', 'Kana', 'US', 'jauer', 'h0', 'hybrid'] +// > +// type ll2 = ParseTransformedExtensionFields<['h0', 'hybrid']> + +/** + * parse `tfield` at unicode transformed extension + * https://unicode.org/reports/tr35/#transformed_extensions + */ +// deno-fmt-ignore +type ParseTransformedExtensionFields< + Chunks extends unknown[], + Sep extends string = '-', + Accumrator extends [unknown[], number, unknown[]] = [[], never, []], + Key extends string = Chunks[0] extends string ? Chunks[0] : never, + KeyChars extends unknown[] = StringToArray, + ResultValue extends [unknown[], unknown[]] = ParseTransformedExtensionFieldsValue< + Shift + >, + FieldsReturn = [Accumrator[0], Accumrator[1], Chunks], +> = Length extends 0 + ? FieldsReturn + : CheckRange extends true // check `tfield` length + ? All, true> extends true // check `tfield` characters + ? All, true> extends true // check `tfield` characters + ? Length extends 0 + ? [never, 10, Chunks] // missing + : [Push]>, Accumrator[1], ResultValue[1]] + : FieldsReturn + : FieldsReturn + : FieldsReturn + +// type ppt1 = ParseTransformedExtensionFields< +// ['h0', 'hybrid'] +// > +// type ppt2 = ParseTransformedExtensionFields< +// ['h0'] +// > + +// deno-fmt-ignore +type ParseTransformedExtensionFieldsValue< + Chunks extends unknown[], + Value extends unknown[] = [], + Chunk extends string = Chunks[0] extends string ? Chunks[0] : never, + ChunkChars extends unknown[] = StringToArray, + ExitReturn = [Value, Chunks], +> = Length extends 0 + ? ExitReturn + : CheckRange extends true // check `tfield` value length + ? All, true> extends true // check `tfield` value characters + ? ParseTransformedExtensionFieldsValue< + Shift, + [...Push] + > + : ExitReturn + : ExitReturn + +/** + * parse private use extensions + * https://unicode.org/reports/tr35/#pu_extensions + * + * = sep [xX] (sep alphanum{1,8})+ ; + */ +// deno-fmt-ignore +export type ParsePuExtension< + Chunks extends unknown[], + Sep extends string = '-', + ResultExts extends [unknown[], unknown[]] = _ParsePuExtension< + Chunks + >, + // NOTE: workaround for `Excessive stack depth comparing types` + ResultExts0 extends unknown[] = ResultExts[0] extends unknown[] ? ResultExts[0] : never, + ResultExts1 extends unknown[] = ResultExts[1] extends unknown[] ? ResultExts[1] : never, + Result extends [PuExtension, number, unknown[]] = Length extends 0 + ? [never, 12, ResultExts1] + : [{ type: 'x'; value: Join }, never, ResultExts1], +> = Result + +export type _ParsePuExtension< + Chunks extends unknown[], + Exts extends unknown[] = [], + Chunk extends string = Chunks[0] extends string ? Chunks[0] : never, + ChunkChars extends unknown[] = StringToArray, + ExitReturn = [Exts, Chunks], +> = Length extends 0 ? ExitReturn + : CheckRange extends true // check value length + ? All, true> extends true // check value characters + ? _ParsePuExtension< + Shift, + [...Push] + > + : ExitReturn + : ExitReturn + +/** + * parse other extension + * https://unicode.org/reports/tr35/#other_extensions + * + * = sep [alphanum-[tTuUxX]] + * (sep alphanum{2,8})+ ; + */ +// deno-fmt-ignore +export type ParseOtherExtension< + Chunks extends unknown[], + Sep extends string = '-', + ResultExts extends [unknown[], unknown[]] = _ParseOtherExtension< + Chunks + >, + Result extends [string, unknown[]] = Length extends 0 + ? ['', ResultExts[1]] + : [Join, ResultExts[1]] +> = Result + +// type o1 = ParseOtherExtension<['foo', 'bar', 'co', 'standard']> + +type _ParseOtherExtension< + Chunks extends unknown[], + Exts extends unknown[] = [], + Chunk extends string = Chunks[0] extends string ? Chunks[0] : never, + ChunkChars extends unknown[] = StringToArray, + ExitReturn = [Exts, Chunks], +> = Length extends 0 ? ExitReturn + : CheckRange extends true // check value length + ? All, true> extends true // check value characters + ? _ParseOtherExtension< + Shift, + [...Push] + > + : ExitReturn + : ExitReturn diff --git a/deno/mod.ts b/deno/mod.ts index d9e6183..a33240b 100644 --- a/deno/mod.ts +++ b/deno/mod.ts @@ -1,2 +1 @@ -// TODO: we must change package structure for deno -export * from '../src/index.ts' +export * from './index.ts' diff --git a/deno/shared.ts b/deno/shared.ts new file mode 100644 index 0000000..68449d8 --- /dev/null +++ b/deno/shared.ts @@ -0,0 +1,141 @@ +const objectToString = Object.prototype.toString +const toTypeString = (value: unknown): string => objectToString.call(value) + +export function isURL(val: unknown): val is URL { + return toTypeString(val) === '[object URL]' +} + +export function isURLSearchParams(val: unknown): val is URLSearchParams { + return toTypeString(val) === '[object URLSearchParams]' +} + +/** + * 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]' +} + +/** + * returns the {@link Intl.Locale | locale} + * + * @param {string | Intl.Locale} val The value for which the 'locale' is requested. + * + * @throws {RangeError} Throws the {@link RangeError} if `val` is not a well-formed BCP 47 language tag. + * + * @returns {Intl.Locale} The locale + */ +export function toLocale(val: string | Intl.Locale): Intl.Locale { + return isLocale(val) ? val : new Intl.Locale(val) +} + +/** + * 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 validateLangTag(lang: string): boolean { + try { + Intl.getCanonicalLocales(lang) + return true + } catch { + return false + } +} + +/** + * 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 === '') + ) +} + +/** + * nomralize the language name + * + * @description + * This function normalizes the locale name defined in {@link https://www.gnu.org/software/gettext/manual/gettext.html#Locale-Names | gettext(libc) style} to {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 language tag} + * + * @example + * ```ts + * const oldLangName = 'en_US' + * const langTag = nomralizeLanguageName(oldLangName) + * conosle.log(langTag) // en-US + * ``` + * + * @param langName The target language name + * + * @returns {string} The normalized language tag + */ +export function normalizeLanguageName(langName: string): string { + const [lang] = langName.split('.') + return lang.replace(/_/g, '-') +} + +/** + * path language parser + */ +export interface PathLanguageParser { + /** + * parse the path that is include language + * + * @param {string | URL} path the target path + * + * @returns {string} the language, if it cannot parse the path is not found, you need to return empty string (`''`) + */ + (path: string | URL): string +} + +/** + * create a parser, which can split with slash `/` + * + * @param index An index of locale, which is included in path + * + * @returns A return a parser, which has {@link PathLanguageParser} interface + */ +export function createPathIndexLanguageParser( + index = 0, +): PathLanguageParser { + return (path: string | URL): string => { + const rawPath = typeof path === 'string' ? path : path.pathname + const normalizedPath = rawPath.split('?')[0] + const parts = normalizedPath.split('/') + if (parts[0] === '') { + parts.shift() + } + return parts.length > index ? parts[index] || '' : '' + } +} + +/** + * A path parser that can get the zeroth part of a path split by `/` as local value + * + * @description + * - `/en/nest/about` -> `en` + */ +export let pathLanguageParser: PathLanguageParser = /* #__PURE__*/ createPathIndexLanguageParser() + +/** + * register the path language parser + * + * @description register a parser to be used in the `getPathLanugage` utility function + * + * @param {PathLanguageParser} parser the path language parser + */ +export function registerPathLanguageParser( + parser: PathLanguageParser, +): void { + pathLanguageParser = parser +} diff --git a/deno/shim.d.ts b/deno/shim.d.ts new file mode 100644 index 0000000..4f541fa --- /dev/null +++ b/deno/shim.d.ts @@ -0,0 +1,3 @@ +declare namespace Intl { + function getCanonicalLocales(locales: string | string[]): string[] +} diff --git a/deno/types.ts b/deno/types.ts new file mode 100644 index 0000000..9d14d31 --- /dev/null +++ b/deno/types.ts @@ -0,0 +1,53 @@ +export type IsNever = [T] extends [never] ? true : false +export type Split = string extends S ? string[] + : S extends `${infer A}${SEP}${infer B}` ? [A, ...(B extends '' ? [] : Split)] + : SEP extends '' ? [] + : [S] +export type Join = T extends [infer F, ...infer R] + ? R['length'] extends 0 ? `${F & string}` + : `${F & string}${U}${Join}` + : never +export type Shift = T extends [unknown, ...infer U] ? U + : never +export type First = T extends [infer A, ...infer rest] ? A + : never +export type Last = [unknown, ...T][T['length']] +export type Length = T['length'] +export type IsEqual = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) + ? true + : false +export type All = T extends [infer L, ...infer R] + ? IsEqual extends true ? All + : false + : true +export type Push = [...T, U] +export type Includes = T extends [infer A, ...infer B] + ? IsEqual extends true ? true : Includes + : false +export type Tuple = readonly unknown[] +export type Concat = [...T, ...U] +export type Filter = T extends [infer R, ...infer Rest] + ? [R] extends [F] ? Filter + : [R, ...Filter] + : [] + +export type UnionToIntersection = ( + U extends unknown ? (arg: U) => 0 : never +) extends (arg: infer I) => 0 ? I + : never +export type LastInUnion = UnionToIntersection< + U extends unknown ? (x: U) => 0 : never +> extends (x: infer L) => 0 ? L + : never +export type UnionToTuple> = [U] extends [never] ? [] + : [...UnionToTuple>, Last] +export type TupleToUnion = T extends Array ? R + : never + +export type StringToUnion = T extends `${infer Letter}${infer Rest}` + ? Letter | StringToUnion + : never + +export type StringToArray = T extends `${infer Letter}${infer Rest}` + ? [Letter, ...StringToArray] + : [] diff --git a/deno/web.ts b/deno/web.ts new file mode 100644 index 0000000..954a5ab --- /dev/null +++ b/deno/web.ts @@ -0,0 +1,347 @@ +import { parse, serialize } from 'npm:cookie-es' +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, PathOptions, QueryOptions } from './http.ts' + +/** + * get languages from header + * + * @description parse header string, default `accept-language` header + * + * @example + * example for Web API request on Deno: + * + * ```ts + * import { getHeaderLanguages } from 'https://esm.sh/@intlify/utils/web' + * + * Deno.serve({ + * port: 8080, + * }, (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 you use `accept-language` header and `*` (any language) or empty string is detected, return an empty array. + */ +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 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 Web API request on Deno: + * + * ```ts + * import { getAcceptLanguage } from 'https://esm.sh/@intlify/utils/web' + * + * Deno.serve({ + * port: 8080, + * }, (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 header, if header is not exists, or `*` (any language), return empty string. + */ +export function getHeaderLanguage( + request: Request, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): string { + return getHeaderLanguages(request, { 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 Web API request on Bun: + * + * import { getHeaderLocales } from '@intlify/utils/web' + * + * Bun.serve({ + * port: 8080, + * fetch(req) { + * const locales = getHeaderLocales(req) + * // ... + * return new Response(`accpected locales: ${locales.map(locale => locale.toString()).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 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( + request: Request, + { + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions = {}, +): Intl.Locale[] { + return mapToLocaleFromLanguageTag(getHeaderLanguages, request, { + 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 Web API request on Bun: + * + * import { getHeaderLocale } from '@intlify/utils/web' + * + * Bun.serve({ + * port: 8080, + * fetch(req) { + * const locale = getHeaderLocale(req) + * // ... + * return new Response(`accpected locale: ${locale.toString()}`) + * }, + * }) + * + * @param {Request} request The {@link Request | request} + * @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 header are not a well-formed BCP 47 language tag. + * + * @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 getHeaderLocale( + request: Request, + { + lang = DEFAULT_LANG_TAG, + name = ACCEPT_LANGUAGE_HEADER, + parser = parseDefaultHeader, + }: HeaderOptions & { lang?: string } = {}, +): Intl.Locale { + return getLocaleWithGetter(() => getHeaderLanguages(request, { name, parser })[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 {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` + * + * @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( + request: Request, + { lang = DEFAULT_LANG_TAG, name = DEFAULT_COOKIE_NAME } = {}, +): Intl.Locale { + const getter = () => { + const cookieRaw = request.headers.get('cookie') + const cookie = parse(cookieRaw || '') + return cookie[name] || lang + } + return getLocaleWithGetter(getter) +} + +/** + * set locale to the response `Set-Cookie` header. + * + * @example + * example for Web API response on Bun: + * + * ```ts + * import { setCookieLocale } from '@intlify/utils/web' + * + * Bun.serve({ + * port: 8080, + * fetch(req) { + * const res = new Response('γ“γ‚“γ«γ‘γ―γ€δΈ–η•ŒοΌ') + * setCookieLocale(res, 'ja-JP') + * // ... + * return res + * }, + * }) + * ``` + * + * @param {Response} response The {@link Response | response} + * @param {string | Intl.Locale} locale The locale value + * @param {CookieOptions} options The 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( + response: Response, + locale: string | Intl.Locale, + options: CookieOptions = { name: DEFAULT_COOKIE_NAME }, +): void { + validateLocale(locale) + const setCookies = getExistCookies( + options.name!, + () => response.headers.getSetCookie(), + ) + const target = serialize(options.name!, locale.toString(), { + path: '/', + ...options, + }) + 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, default {@link pathLanguageParser}, 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 + * + * @description + * The value depends on the environments. if you use this function on the browser, you can get the languages, that are set in the browser, else if you use this function on the server side (Deno only), that value is the languages set in the server. + * + * @throws Throws the {@link Error} if the `navigator` is not exists. + * + * @returns {Array} {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 language tags} + */ +function getNavigatorLanguages(): readonly string[] { + if (typeof navigator === 'undefined') { + throw new Error('not support `navigator`') + } + return navigator.languages +} + +/** + * get navigator language + * + * @description + * The value depends on the environments. if you use this function on the browser, you can get the languages, that are set in the browser, else if you use this function on the server side (Deno only), that value is the language set in the server. + * + * @throws Throws the {@link Error} if the `navigator` is not exists. + * + * @returns {string} {@link https://datatracker.ietf.org/doc/html/rfc4646#section-2.1 | BCP 47 language tag} + */ +function getNavigatorLanguage(): string { + if (typeof navigator === 'undefined') { + throw new Error('not support `navigator`') + } + return navigator.language +} + +/** + * get navigator locales + * + * @description + * This function is a wrapper that maps in {@link Intl.Locale} in `navigator.languages`. + * This function return values depends on the environments. if you use this function on the browser, you can get the languages, that are set in the browser, else if you use this function on the server side (Deno only), that value is the languages set in the server. + * + * @throws Throws the {@link Error} if the `navigator` is not exists. + * + * @returns {Array} + */ +export function getNavigatorLocales(): readonly Intl.Locale[] { + return getNavigatorLanguages().map((lang) => new Intl.Locale(lang)) +} + +/** + * get navigator locale + * + * @description + * This function is the {@link Intl.Locale} wrapper of `navigator.language`. + * The value depends on the environments. if you use this function on the browser, you can get the languages, that are set in the browser, else if you use this function on the server side (Deno only), that value is the language set in the server. + * + * @throws Throws the {@link Error} if the `navigator` is not exists. + * + * @returns {Intl.Locale} + */ +export function getNavigatorLocale(): Intl.Locale { + return new Intl.Locale(getNavigatorLanguage()) +} diff --git a/package.json b/package.json index 387ab6a..0817a03 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "fix": "run-p lint format", "lint": "deno lint", "format": "deno fmt", - "build": "unbuild", + "build": "unbuild && bun run ./scripts/deno.ts", "test": "npm run test:typecheck && npm run test:unit", "test:unit": "NODE_OPTIONS=--experimental-vm-modules vitest run ./src", "test:typecheck": "vitest typecheck --config ./vitest.type.config.ts --run", diff --git a/scripts/deno.ts b/scripts/deno.ts new file mode 100644 index 0000000..18f95da --- /dev/null +++ b/scripts/deno.ts @@ -0,0 +1,53 @@ +import { promises as fs } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isExists } from './utils.ts' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) + +const TARGETS = [ + 'constants.ts', + 'http.ts', + 'index.ts', + 'locale.ts', + 'shared.ts', + 'shim.d.ts', + 'types.ts', + 'web.ts', +] + +async function main() { + const projectPath = resolve(__dirname, '..') + const sourcePath = resolve(__dirname, '../src') + const destPath = resolve(__dirname, '../deno') + + if (!await isExists(destPath)) { + throw new Error(`not found ${destPath}`) + } + + console.log('copy some source files to denoland hosting directries πŸ¦• ...') + + // copy docs + for (const p of ['README.md', 'LICENSE']) { + fs.copyFile(resolve(projectPath, p), resolve(destPath, p)) + console.log(`${resolve(projectPath, p)} -> ${resolve(destPath, p)}`) + } + + // copy source files + for (const target of TARGETS) { + fs.copyFile(resolve(sourcePath, target), resolve(destPath, target)) + console.log(`${resolve(sourcePath, target)} -> ${resolve(destPath, target)}`) + } + + // add `npm:` prefix + const webCode = await fs.readFile(resolve(destPath, 'web.ts'), 'utf-8') + const replacedWebCode = webCode.replace('from \'cookie-es\'', 'from \'npm:cookie-es\'') + await fs.writeFile(resolve(destPath, 'web.ts'), replacedWebCode, 'utf8') + + console.log('... πŸ¦• done!') +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json index 7c4dd51..c5a66fa 100644 --- a/tsconfig.vitest.json +++ b/tsconfig.vitest.json @@ -1,4 +1,4 @@ { "extends": ["./tsconfig.json"], - "exclude": ["./playground"] + "exclude": ["./playground", "./deno"] }