diff --git a/bun.lockb b/bun.lockb index 6536f89..39df61b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/mod.ts b/mod.ts deleted file mode 100644 index 881e878..0000000 --- a/mod.ts +++ /dev/null @@ -1,4 +0,0 @@ -console.log('main navigator.language', navigator.language) -console.log('main navigator.languages', navigator.languages) - -new Worker(new URL('./worker.ts', import.meta.url).href, { type: 'module' }) diff --git a/package.json b/package.json index 96cf1b6..1573cf1 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,9 @@ "lint": "deno lint", "format": "deno fmt", "build": "unbuild", - "test": "vitest run", + "test": "npm run test:typecheck && npm run test:unit", + "test:unit": "vitest run", + "test:typecheck": "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,7 +86,8 @@ "devDependencies": { "@types/node": "^20.6.0", "@types/supertest": "^2.0.12", - "@vitest/coverage-v8": "^0.34.4", + "@vitest/coverage-v8": "^1.0.0-beta.1", + "bun-types": "latest", "bumpp": "^9.2.0", "cookie-es": "^1.0.0", "gh-changelogen": "^0.2.8", @@ -93,7 +96,7 @@ "supertest": "^6.3.3", "typescript": "^5.2.2", "unbuild": "^2.0.0", - "vitest": "^0.34.4" + "vitest": "^1.0.0-beta.1" }, "workspaces": [ "playground/*" diff --git a/playground/bun/index.ts b/playground/bun/index.ts index dfb1f88..d8f4638 100644 --- a/playground/bun/index.ts +++ b/playground/bun/index.ts @@ -1,10 +1,11 @@ -import { getLocale } from '@intlify/utils/web' +import { getHeaderLocale } from '@intlify/utils' const port = 8124 +// @ts-ignore: this is example Bun.serve({ port, fetch(req: Request) { - const locale = getLocale(req) + const locale = getHeaderLocale(req) return new Response(`detect locale: ${locale.toString()}`) }, }) diff --git a/playground/bun/package.json b/playground/bun/package.json index 3a2c2c3..e308591 100644 --- a/playground/bun/package.json +++ b/playground/bun/package.json @@ -13,6 +13,6 @@ "typescript": "^5.0.0" }, "dependencies": { - "@intlify/utils": "npm:@intlify/utils-edge@0.2.0-28254719.6209414" + "@intlify/utils": "npm:@intlify/utils-edge@0.5.0-28266797.0fa17f3" } } diff --git a/playground/deno/main.ts b/playground/deno/main.ts index 99084f1..c987ab0 100644 --- a/playground/deno/main.ts +++ b/playground/deno/main.ts @@ -1,6 +1,8 @@ +// @ts-ignore: this is example import { getHeaderLanguages } from 'https://esm.sh/@intlify/utils/web' const port = 8125 +// @ts-ignore: this is example Deno.serve({ port, }, (req: Request) => { diff --git a/playground/node/package.json b/playground/node/package.json index 517efaa..07be27c 100644 --- a/playground/node/package.json +++ b/playground/node/package.json @@ -5,7 +5,7 @@ "dev": "npx tsx index.ts" }, "dependencies": { - "@intlify/utils": "npm:@intlify/utils-edge@0.2.0-28254719.6209414" + "@intlify/utils": "npm:@intlify/utils-edge@0.5.0-28266797.0fa17f3" }, "devDependencies": { "@types/node": "^20.6.0", diff --git a/src/locale.test-d.ts b/src/locale.test-d.ts new file mode 100644 index 0000000..caeaaf1 --- /dev/null +++ b/src/locale.test-d.ts @@ -0,0 +1,280 @@ +import { expectTypeOf, test } from 'vitest' + +import type { + CheckRange, + ParseLangSubtag, + ParseRegionSubtag, + ParseScriptSubtag, + ParseUnicodeLanguageId, + ParseVariantsSubtag, +} from './locale.ts' + +test('CheckRange', () => { + type Indexes = [2, 3, 5, 6, 7, 8] + // 0 + expectTypeOf>().toMatchTypeOf() + // 2 + expectTypeOf>().toMatchTypeOf() + // 3 + expectTypeOf>().toMatchTypeOf() + // 4 + expectTypeOf>().toMatchTypeOf< + false + >() + // 5 + expectTypeOf>().toMatchTypeOf< + true + >() + // 8 + expectTypeOf>() + .toMatchTypeOf< + true + >() + // 9 + expectTypeOf< + CheckRange<['c', 'h', 'i', 'l', 'a', 'n', 'd', 'a', 'b'], Indexes> + >() + .toMatchTypeOf< + false + >() + + expectTypeOf< + CheckRange<['1', '2', '3', '4'], [4]> + >() + .toMatchTypeOf< + true + >() + expectTypeOf< + CheckRange<['1', '2', '3'], [4]> + >() + .toMatchTypeOf< + false + >() +}) + +test('ParseLangSubtag', () => { + /** + * Success cases + */ + + // 2 chars + expectTypeOf>().toMatchTypeOf< + ['ja', never] + >() + // 3 chars + expectTypeOf>().toMatchTypeOf< + ['jpn', never] + >() + // 7 chars + expectTypeOf>().toMatchTypeOf<['english', never]>() + // 'root' is special (4 chars) + expectTypeOf>().toMatchTypeOf<['root', never]>() + // upper case + expectTypeOf>().toMatchTypeOf< + ['JA', never] + >() + // mixied case + expectTypeOf>().toMatchTypeOf< + ['Ja', never] + >() + + /** + * Failed cases + */ + + // empty + expectTypeOf>().toMatchTypeOf< + [never, 1] + >() + // no-alphabet + expectTypeOf>().toMatchTypeOf< + [never, 2] + >() + // never + expectTypeOf>().toMatchTypeOf< + [never, 1] + >() + // range + expectTypeOf>().toMatchTypeOf< + [never, 3] + >() + expectTypeOf>().toMatchTypeOf< + [never, 3] + >() + // not string + expectTypeOf>().toMatchTypeOf() +}) + +test('ParseScriptSubtag', () => { + /** + * Success cases + */ + + // 4 chars + expectTypeOf>().toMatchTypeOf< + ['kana', never] + >() + + // empty + expectTypeOf>().toMatchTypeOf< + [never, never] + >() + + // upper case + expectTypeOf>().toMatchTypeOf< + ['Kana', never] + >() + + /** + * Failed cases + */ + + // no-alphabet + expectTypeOf>().toMatchTypeOf< + [never, 4] + >() + // range + expectTypeOf>().toMatchTypeOf< + [never, 5] + >() + expectTypeOf>().toMatchTypeOf< + [never, 5] + >() + // not string + expectTypeOf>().toMatchTypeOf< + never + >() +}) + +test('ParseRegionSubtag', () => { + /** + * Success cases + */ + + // 2 chars (alpha) + expectTypeOf>().toMatchTypeOf< + ['jp', never] + >() + // 3 chars (digit) + expectTypeOf>().toMatchTypeOf< + ['012', never] + >() + // empty + expectTypeOf>().toMatchTypeOf< + [never, never] + >() + // upper case + expectTypeOf>().toMatchTypeOf< + ['JP', never] + >() + + /** + * Failed cases + */ + + // no all-alphabet + expectTypeOf>().toMatchTypeOf< + [never, 6] + >() + // no all-digits + expectTypeOf>().toMatchTypeOf< + [never, 6] + >() + // range + expectTypeOf>().toMatchTypeOf< + [never, 7] + >() + expectTypeOf>().toMatchTypeOf< + [never, 7] + >() + expectTypeOf>().toMatchTypeOf< + [never, 7] + >() + expectTypeOf>().toMatchTypeOf< + [never, 7] + >() + // not string + expectTypeOf>().toMatchTypeOf< + never + >() +}) + +test('ParseVariantsSubtag', () => { + /** + * Success cases + */ + + // 3 chars, all digits + expectTypeOf>().toMatchTypeOf< + [['123'], never] + >() + // 3 chars, first digit and alphabets + expectTypeOf>().toMatchTypeOf< + [['1ab'], never] + >() + // 5 chars, all alphabets + expectTypeOf>().toMatchTypeOf< + [['abcde'], never] + >() + // 7 chars, alphabets and digits + expectTypeOf>().toMatchTypeOf< + [['ab12cde', 'abcde123'], never] + >() + + /** + * Failed cases + */ + + // range 1 + expectTypeOf>().toMatchTypeOf< + [[], never] + >() + // range 2 + expectTypeOf>().toMatchTypeOf< + [[], never] + >() + // range 4 + expectTypeOf>().toMatchTypeOf< + [[], never] + >() + // range 9 + expectTypeOf>().toMatchTypeOf< + [[], never] + >() + + // 3 chars, first alphabet and digits + expectTypeOf>().toMatchTypeOf< + [[], never] + >() + // 3 chars, all alphabets + expectTypeOf>().toMatchTypeOf< + [[], never] + >() + + // not string + expectTypeOf>().toMatchTypeOf< + [[], never] + >() +}) + +test('ParseUnicodeLangugageId', () => { + /** + * Success cases + */ + expectTypeOf>().toMatchTypeOf< + [{ lang: 'ja'; script: 'Kana'; region: 'jp'; variants: ['jauer'] }, never] + >() + + /** Erros */ + expectTypeOf>().toMatchTypeOf< + [ + { lang: never; script: never; region: never; variants: ['jauer'] }, + [ + 'requires 2-3 or 5-8 alphabet lower characters', + 'unicode script subtag requires 4 alphabet lower characters', + 'unicode region subtag requires 2 alphabet lower characters or 3 digits', + 'duplicate unicode variant subtag', + ], + ] + >() +}) diff --git a/src/locale.ts b/src/locale.ts new file mode 100644 index 0000000..cd73685 --- /dev/null +++ b/src/locale.ts @@ -0,0 +1,337 @@ +import type { + All, + Concat, + Filter, + First, + Includes, + IsNever, + Length, + Push, + Shift, + Split, + StringToArray, + TupleToUnion, + UnionToTuple, +} from './types.ts' + +export interface UnicodeLocaleId { + lang: UnicodeLanguageId + extensions: Array< + UnicodeExtension | TransformedExtension | PuExtension | OtherExtension + > +} + +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> +> + +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', + 1024: 'Unexpected error', +} as const + +/** + * parse unicode language id + * https://unicode.org/reports/tr35/#unicode_language_id + */ +export type ParseUnicodeLanguageId< + T extends string, + ErrorMsg extends Record = typeof localeErrors, + S extends unknown[] = Split, + Lang extends [string, number] = ParseLangSubtag>, + Rest1 extends unknown[] = Shift, + Script extends [string, number] = ParseScriptSubtag>, + Rest2 extends unknown[] = Shift, + Region extends [string, number] = ParseRegionSubtag>, + Rest3 extends unknown[] = Shift, + Variants extends [string[], number | never] = ParseVariantsSubtag< + Rest3 + >, + Errors extends unknown[] = Filter<[ + ErrorMsg[Lang[1]], + ErrorMsg[Script[1]], + ErrorMsg[Region[1]], + ErrorMsg[Variants[1]], + ], never>, +> = [ + { + lang: Lang[0] + script: Script[0] + region: Region[0] + variants: Variants[0] + }, + Length extends 0 ? never : Errors, +] + +/** + * parse unicode language subtag + * https://unicode.org/reports/tr35/#unicode_language_subtag + */ +// deno-fmt-ignore +export type ParseLangSubtag< + T, + R extends [string, number] = IsNever extends true + ? [never, 1] // missing + : T extends '' + ? [never, 1] // missing + : T extends 'root' + ? ['root', never] // 'root' is special case + : T extends string + ? ParseUnicodeLanguageSubtag + : never // unexpected +> = R + +/** + * 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< + T extends string, + Chars extends unknown[] = StringToArray, +> = CheckRange extends true + ? Includes, false> extends true // check if all chars are alphabets + ? [never, 2] // malformed + : [T, never] + : [never, 3] // require characters length + +/** + * parse unicode script subtag + * https://unicode.org/reports/tr35/#unicode_script_subtag + */ +// deno-fmt-ignore +export type ParseScriptSubtag< + T, + R extends [string, number] = IsNever extends true + ? [never, never] // missing + : T extends '' + ? [never, never] // missing + : T extends string + ? ParseUnicodeScriptSubtag + : never // unexpected +> = R + +/** + * 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< +T extends string, +Chars extends unknown[] = StringToArray, +> = CheckRange extends true + ? Includes, false> extends true // check if all chars are alphabets + ? [never, 4] // malformed + : [T, never] + : [never, 5] // require characters length + +/** + * parse unicode region subtag + * https://unicode.org/reports/tr35/#unicode_region_subtag + */ +// deno-fmt-ignore +export type ParseRegionSubtag< + T, + R extends [string, number] = IsNever extends true + ? [never, never] // missing + : T extends '' + ? [never, never] // missing + : T extends string + ? ParseUnicodeRegionSubtag + : never, // unexpected +> = R + +/** + * 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< + T extends string, + Chars extends unknown[] = StringToArray, + HasAlphabetsOnly = All, true>, + HasDigitsOnly = All, true>, +> = CheckRange extends true + ? Length extends 2 + ? HasAlphabetsOnly extends true + ? [T, never] + : HasDigitsOnly extends true + ? [never, 7] // require characters length + : [never, 6] // malformed + : Length extends 3 + ? HasDigitsOnly extends true + ? [T, never] + : HasAlphabetsOnly extends true + ? [never, 7] // require characters length + : [never, 6] // malformed + : [never, 7] // require characters length + : [never, 7] // require characters length + +/** + * parse unicode variant subtag + * https://unicode.org/reports/tr35/#unicode_variant_subtag + */ +export type ParseVariantsSubtag< + T extends unknown[], + R extends [string[], number | never] = _ParseVariantsSubtag, +> = R + +// deno-fmt-ignore +type _ParseVariantsSubtag< + T 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, + > = IsNever extends false + ? [[...Accumrator[0]], Accumrator[1]] + : Duplicate extends true + ? [[...Accumrator[0]], 8] + : IsNever extends true + ? [[...Accumrator[0]], never] + : _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< + T 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 + ? [T, never] + : [never, never] // ignore + : [never, never] // ignore + : Length extends 4 + ? [never, never] // ignore + : CheckRange extends true + ? All, true> extends true// capture alphanum + ? [T, never] + : [never, never] // ignore + : [never, never] // ignore + +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 +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3d533f7 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,50 @@ +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 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/tsconfig.json b/tsconfig.json index 9da47a2..6d8ee1a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ /* Projects */ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ @@ -12,7 +12,11 @@ /* Language and Environment */ "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "lib": [ + // "ESNext", + // "DOM", + // "DOM.Iterable" + // ], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ @@ -59,7 +63,7 @@ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ // "outDir": "./", /* Specify an output folder for all emitted files. */ // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ diff --git a/vitest.config.ts b/vitest.config.ts index a93210a..c7d146f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,8 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { includeSource: ['src/**/*.{js,ts}'], + // typecheck: { + // ignoreSourceErrors: true, + // }, }, })