@quietui/squeak 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.editorconfig ADDED
@@ -0,0 +1,15 @@
1
+ # http://editorconfig.org
2
+
3
+ root = true
4
+
5
+ [*]
6
+ charset = utf-8
7
+ indent_style = space
8
+ indent_size = 2
9
+ end_of_line = lf
10
+ insert_final_newline = true
11
+ trim_trailing_whitespace = true
12
+
13
+ [*.md]
14
+ insert_final_newline = false
15
+ trim_trailing_whitespace = false
@@ -0,0 +1,6 @@
1
+ *.md
2
+ .github
3
+ dist
4
+ node_modules
5
+ package-lock.json
6
+ tsconfig.json
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ - Initial fork
package/LICENSE.md ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2024 A Beautiful Site, LLC
2
+
3
+ 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:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ 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.
package/README.md ADDED
@@ -0,0 +1,253 @@
1
+ # Squeak
2
+
3
+ Squeak is a tiny, zero-dependency library that provides a [Reactive Controller](https://lit.dev/docs/composition/controllers/) for localizing terms, dates, and numbers, and currency across one or more custom elements in a component library. It _does not_ aim to replicate a full-blown localization tool. For that, you should use something like [i18next](https://www.i18next.com/).
4
+
5
+ Reactive Controllers are supported by Lit out of the box, but they're designed to be generic so other libraries can elect to support them either natively or through an adapter. If you're favorite custom element authoring library doesn't support Reactive Controllers yet, consider asking the maintainers to add support for them!
6
+
7
+ ## Overview
8
+
9
+ Here's an example of how Squeak can be used to create a localized custom element with Lit.
10
+
11
+ ```ts
12
+ import { Localize, registerTranslation } from '@quietui/squeak';
13
+
14
+ // Note: translations can also be lazy loaded (see "Registering Translations" below)
15
+ import en from '../translations/en';
16
+ import es from '../translations/es';
17
+
18
+ registerTranslation(en, es);
19
+
20
+ @customElement('my-element')
21
+ export class MyElement extends LitElement {
22
+ private localize = new Localize(this);
23
+
24
+ @property() lang: string;
25
+
26
+ render() {
27
+ return html`
28
+ <h1>${this.localize.term('hello_world')}</h1>
29
+ `;
30
+ }
31
+ }
32
+ ```
33
+
34
+ To set the page locale, apply the desired `lang` attribute to the `<html>` element.
35
+
36
+ ```html
37
+ <html lang="es">
38
+ ...
39
+ </html>
40
+ ```
41
+
42
+ Changes to `<html lang>` will trigger an update to all localized components automatically.
43
+
44
+ ## Why use Squeak instead of a proper i18n library?
45
+
46
+ It's not uncommon for a custom element to require localization, but implementing it at the component level is challenging. For example, how should we provide a translation for this close button that exists in a custom element's shadow root?
47
+
48
+ ```html
49
+ #shadow-root
50
+ <button type="button" aria-label="Close">
51
+ <svg>...</svg>
52
+ </button>
53
+ ```
54
+
55
+ Typically, custom element authors dance around the problem by exposing attributes or properties for such purposes.
56
+
57
+ ```html
58
+ <my-element close-label="${t('close')}">
59
+ ...
60
+ </my-element>
61
+ ```
62
+
63
+ But this approach offloads the problem to the user so they have to provide every term, every time. It also doesn't scale with more complex components that have more than a handful of terms to be translated.
64
+
65
+ This is the use case this library is solving for. It is not intended to solve localization at the framework level. There are much better tools for that.
66
+
67
+ ## How it works
68
+
69
+ To achieve this goal, we lean on HTML’s [`lang`](~https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang~) attribute to determine what language should be used. The default locale is specified by `<html lang="...">`, but any localized element can be scoped to a locale by setting its `lang` attribute. This means you can have more than one language per page, if desired.
70
+
71
+ ```html
72
+ <html lang="en">
73
+ <body>
74
+ <my-element>This element will be English</my-element>
75
+ <my-element lang="es">This element will be Spanish</my-element>
76
+ <my-element lang="fr">This element will be French</my-element>
77
+ </body>
78
+ </html>
79
+ ```
80
+
81
+ This library provides a set of tools to localize dates, currencies, numbers, and terms in your custom element library with a minimal footprint. Reactivity is achieved with a [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) that listens for `lang` changes on `<html>`.
82
+
83
+ By design, `lang` attributes on ancestor elements are ignored. This is for performance reasons, as there isn't an efficient way to detect the "current language" of an arbitrary element. I consider this a gap in the platform and [I've proposed properties](https://github.com/whatwg/html/issues/7039) to make this lookup less expensive.
84
+
85
+ Fortunately, the majority of use cases appear to favor a single language per page. However, multiple languages per page are also supported, but you'll need to explicitly set the `lang` attribute on all components whose language differs from the one set in `<html lang>`.
86
+
87
+ ## Usage
88
+
89
+ First, install the library.
90
+
91
+ ```bash
92
+ npm install @quietui/squeak
93
+ ```
94
+
95
+ Next, follow these steps to localize your components.
96
+
97
+ 1. Create a translation
98
+ 2. Register the translation
99
+ 3. Localize your components
100
+
101
+ ### Creating a translation
102
+
103
+ All translations must extend the `Translation` type and implement the required meta properties (denoted by a `$` prefix). Additional terms can be implemented as show below.
104
+
105
+ ```ts
106
+ // en.ts
107
+ import type { Translation } from '@quietui/squeak';
108
+
109
+ const translation: Translation = {
110
+ $code: 'en',
111
+ $name: 'English',
112
+ $dir: 'ltr',
113
+
114
+ // Simple terms
115
+ upload: 'Upload',
116
+
117
+ // Terms with placeholders
118
+ greetUser: (name: string) => `Hello, ${name}!`,
119
+
120
+ // Plurals
121
+ numFilesSelected: (count: number) => {
122
+ if (count === 0) return 'No files selected';
123
+ if (count === 1) return '1 file selected';
124
+ return `${count} files selected`;
125
+ }
126
+ };
127
+
128
+ export default translation;
129
+ ```
130
+
131
+ ### Registering translations
132
+
133
+ Once you've created a translation, you need to register it before use. The first translation you register should be the default translation. The default translation acts as a fallback in case a term can't be found in another translation. As such, the default translation should be assumed to be complete at all times.
134
+
135
+ ```ts
136
+ import { registerDefaultTranslation } from '@quietui/squeak';
137
+ import en from './en.js';
138
+
139
+ registerDefaultTranslation(en);
140
+ ```
141
+
142
+ To register additional translations, call the `registerTranslation()` method. This example imports and register two more translations up front.
143
+
144
+ ```ts
145
+ import { registerTranslation } from '@quietui/squeak';
146
+ import es from './es.js';
147
+ import ru from './ru.js';
148
+
149
+ registerTranslation(es, ru);
150
+ ```
151
+
152
+ Translations registered with country codes such as `en-gb` are also supported. For example, if the user's language is set to _German (Austria)_, or `de-at`, the localizer will first look for a translation registered as `de-at` and then fall back to `de`. Thus, it's a good idea to register a base translation (e.g. `de`) to accompany those with country codes (e.g. `de-*`). This ensures users of unsupported regions will still receive a comprehensible translation.
153
+
154
+ #### Dynamic registrations
155
+
156
+ It's important to note that translations _do not_ have to be registered up front. You can register them on demand as the language changes in your app. Upon registration, localized components will update automatically.
157
+
158
+ Here's a sample function that dynamically loads a translation.
159
+
160
+ ```ts
161
+ import { registerTranslation } from '@quietui/squeak';
162
+
163
+ async function changeLanguage(lang) {
164
+ const availableTranslations = ['en', 'es', 'fr', 'de'];
165
+
166
+ if (availableTranslations.includes(lang)) {
167
+ const translation = await import(`/path/to/translations/${lang}.js`);
168
+ registerTranslation(translation);
169
+ }
170
+ }
171
+ ```
172
+
173
+ ### Localizing components
174
+
175
+ You can use Squeak with any library that supports [Lit's Reactive Controller pattern](https://lit.dev/docs/composition/controllers/). In Lit, a localized custom element will look something like this.
176
+
177
+ ```ts
178
+ import { LitElement } from 'lit';
179
+ import { customElement } from 'lit/decorators.js';
180
+ import { Localize } from '@quietui/squeak/dist/lit.js';
181
+
182
+ @customElement('my-element')
183
+ export class MyElement extends LitElement {
184
+ private localize = new Localize(this);
185
+
186
+ // Make sure to make `dir` and `lang` reactive so the component will respond to changes to its own attributes
187
+ @property() dir: string;
188
+ @property() lang: string;
189
+
190
+ render() {
191
+ return html`
192
+ <!-- Terms -->
193
+ ${this.localize.term('hello')}
194
+
195
+ <!-- Numbers/currency -->
196
+ ${this.localize.number(1000, { style: 'currency', currency: 'USD'})}
197
+
198
+ <!-- Dates -->
199
+ ${this.localize.date('2021-09-15 14:00:00 ET'), { month: 'long', day: 'numeric', year: 'numeric' }}
200
+
201
+ <!-- Relative times -->
202
+ ${this.localize.relativeTime(2, 'day'), { style: 'short' }}
203
+
204
+ <!-- Determining language -->
205
+ ${this.localize.lang()}
206
+
207
+ <!-- Determining directionality, e.g. 'ltr' or 'rtl' -->
208
+ ${this.localize.dir()}
209
+ `;
210
+ }
211
+ }
212
+ ```
213
+
214
+ ### Other methods
215
+
216
+ - `this.localize.exists()` - Determines if the specified term exists, optionally checking the default translation.
217
+ - `this.localize.update()` - Forces all localized components to update. Most users won't ever need to call this.
218
+
219
+ ## Typed translations and arguments
220
+
221
+ Because translations are defined by the user, there's no way for TypeScript to automatically know about the terms you've defined. This means you won't get strongly typed arguments when calling `this.localize.term()`. However, you can solve this by extending `Translation` and `Localize`.
222
+
223
+ In a separate file, e.g. `my-localize.ts`, add the following code.
224
+
225
+ ```ts
226
+ import { Localize as DefaultLocalize } from '@quietui/squeak';
227
+
228
+ // Extend the default controller with your custom translation
229
+ export class Localize extends DefaultLocalize<MyTranslation> {}
230
+
231
+ // Export `registerTranslation` so you can import everything from this file
232
+ export { registerTranslation } from '@quietui/squeak';
233
+
234
+ // Define your translation terms here
235
+ export interface MyTranslation extends Translation {
236
+ myTerm: string;
237
+ myOtherTerm: string;
238
+ myTermWithArgs: (count: string) => string;
239
+ }
240
+ ```
241
+
242
+ Now you can import `MyLocalize` and get strongly typed translations when you use `this.localize.term()`!
243
+
244
+ ## Advantages
245
+
246
+ - Zero dependencies
247
+ - Extremely lightweight
248
+ - Supports simple terms, plurals, and complex translations
249
+ - Supports dates, numbers, and currencies using built-in [`Intl` APIs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)
250
+ - Good DX for custom element authors and consumers
251
+ - Intuitive API for custom element authors
252
+ - Consumers only need to load the translations they want and set the `lang` attribute
253
+ - Translations can be loaded up front or on demand
package/RELEASE.md ADDED
@@ -0,0 +1,8 @@
1
+ # Release
2
+
3
+ This package gets published from the root folder. To release:
4
+
5
+ ```bash
6
+ npm version major|minor|patch
7
+ npm publish
8
+ ```
@@ -0,0 +1,28 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ export type FunctionParams<T> = T extends (...args: infer U) => string ? U : [];
3
+ export interface Translation {
4
+ $code: string;
5
+ $name: string;
6
+ $dir: 'ltr' | 'rtl';
7
+ }
8
+ export interface ExistsOptions {
9
+ lang: string;
10
+ includeDefault: boolean;
11
+ }
12
+ export declare function registerDefaultTranslation(translation: Translation): void;
13
+ export declare function registerTranslation(...translation: Translation[]): void;
14
+ export declare function update(): void;
15
+ export declare class Localize<UserTranslation extends Translation> implements ReactiveController {
16
+ host: ReactiveControllerHost & HTMLElement;
17
+ constructor(host: ReactiveControllerHost & HTMLElement);
18
+ hostConnected(): void;
19
+ hostDisconnected(): void;
20
+ dir(): string;
21
+ lang(): string;
22
+ private getTranslationData;
23
+ exists<K extends keyof UserTranslation>(key: K, options: Partial<ExistsOptions>): boolean;
24
+ term<K extends keyof UserTranslation>(key: K, ...args: FunctionParams<UserTranslation[K]>): string;
25
+ date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions): string;
26
+ number(numberToFormat: number | string, options?: Intl.NumberFormatOptions): string;
27
+ relativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string;
28
+ }
package/dist/index.js ADDED
@@ -0,0 +1,105 @@
1
+ const connectedElements = new Set();
2
+ const documentElementObserver = new MutationObserver(update);
3
+ const translations = new Map();
4
+ let documentDirection = document.documentElement.dir || 'ltr';
5
+ let documentLanguage = document.documentElement.lang || navigator.language;
6
+ let defaultTranslation;
7
+ documentElementObserver.observe(document.documentElement, {
8
+ attributes: true,
9
+ attributeFilter: ['dir', 'lang']
10
+ });
11
+ export function registerDefaultTranslation(translation) {
12
+ defaultTranslation = translation;
13
+ update();
14
+ }
15
+ export function registerTranslation(...translation) {
16
+ translation.map(t => {
17
+ const code = t.$code.toLowerCase();
18
+ if (translations.has(code)) {
19
+ translations.set(code, Object.assign(Object.assign({}, translations.get(code)), t));
20
+ }
21
+ else {
22
+ translations.set(code, t);
23
+ }
24
+ });
25
+ update();
26
+ }
27
+ export function update() {
28
+ documentDirection = document.documentElement.dir || 'ltr';
29
+ documentLanguage = document.documentElement.lang || navigator.language;
30
+ [...connectedElements.keys()].map((el) => {
31
+ if (typeof el.requestUpdate === 'function') {
32
+ el.requestUpdate();
33
+ }
34
+ });
35
+ }
36
+ export class Localize {
37
+ constructor(host) {
38
+ this.host = host;
39
+ this.host.addController(this);
40
+ }
41
+ hostConnected() {
42
+ connectedElements.add(this.host);
43
+ }
44
+ hostDisconnected() {
45
+ connectedElements.delete(this.host);
46
+ }
47
+ dir() {
48
+ return `${this.host.dir || documentDirection}`.toLowerCase();
49
+ }
50
+ lang() {
51
+ return `${this.host.lang || documentLanguage}`.toLowerCase();
52
+ }
53
+ getTranslationData(lang) {
54
+ var _a, _b;
55
+ const locale = new Intl.Locale(lang.replace(/_/g, '-'));
56
+ const language = locale === null || locale === void 0 ? void 0 : locale.language.toLowerCase();
57
+ const region = (_b = (_a = locale === null || locale === void 0 ? void 0 : locale.region) === null || _a === void 0 ? void 0 : _a.toLowerCase()) !== null && _b !== void 0 ? _b : '';
58
+ const primary = translations.get(`${language}-${region}`);
59
+ const secondary = translations.get(language);
60
+ return { locale, language, region, primary, secondary };
61
+ }
62
+ exists(key, options) {
63
+ var _a;
64
+ const { primary, secondary } = this.getTranslationData((_a = options.lang) !== null && _a !== void 0 ? _a : this.lang());
65
+ options = Object.assign({ includeDefault: false }, options);
66
+ if ((primary && primary[key]) ||
67
+ (secondary && secondary[key]) ||
68
+ (options.includeDefault && defaultTranslation && defaultTranslation[key])) {
69
+ return true;
70
+ }
71
+ return false;
72
+ }
73
+ term(key, ...args) {
74
+ const { primary, secondary } = this.getTranslationData(this.lang());
75
+ let term;
76
+ if (primary && primary[key]) {
77
+ term = primary[key];
78
+ }
79
+ else if (secondary && secondary[key]) {
80
+ term = secondary[key];
81
+ }
82
+ else if (defaultTranslation && defaultTranslation[key]) {
83
+ term = defaultTranslation[key];
84
+ }
85
+ else {
86
+ console.error(`No translation found for: ${String(key)}`);
87
+ return String(key);
88
+ }
89
+ if (typeof term === 'function') {
90
+ return term(...args);
91
+ }
92
+ return term;
93
+ }
94
+ date(dateToFormat, options) {
95
+ dateToFormat = new Date(dateToFormat);
96
+ return new Intl.DateTimeFormat(this.lang(), options).format(dateToFormat);
97
+ }
98
+ number(numberToFormat, options) {
99
+ numberToFormat = Number(numberToFormat);
100
+ return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(this.lang(), options).format(numberToFormat);
101
+ }
102
+ relativeTime(value, unit, options) {
103
+ return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
104
+ }
105
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@quietui/squeak",
3
+ "version": "1.0.0",
4
+ "description": "A tiny, zero-dependency library that provides a Reactive Controller for localizing terms, dates, and numbers, and currency across one or more custom elements in a component library.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "type": "module",
8
+ "types": "dist/index.d.ts",
9
+ "scripts": {
10
+ "start": "tsc -w",
11
+ "build": "tsc",
12
+ "clean": "node ./scripts/clean.js",
13
+ "prebuild": "npm run clean",
14
+ "prepublishOnly": "npm run build"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/quietui/squeak.git"
19
+ },
20
+ "keywords": [
21
+ "web components",
22
+ "custom elements",
23
+ "localization",
24
+ "internationalization",
25
+ "i18n",
26
+ "l10n"
27
+ ],
28
+ "author": "Cory LaViska",
29
+ "license": "MIT",
30
+ "bugs": {
31
+ "url": "https://github.com/quietui/squeak/issues"
32
+ },
33
+ "homepage": "https://github.com/quietui/squeak#readme",
34
+ "devDependencies": {
35
+ "del": "^7.1.0",
36
+ "lit": "^3.1.2",
37
+ "prettier": "^3.2.5",
38
+ "typescript": "^5.3.3"
39
+ }
40
+ }
@@ -0,0 +1,18 @@
1
+ /** @type {import("prettier").Config} */
2
+ export default {
3
+ arrowParens: 'avoid',
4
+ bracketSpacing: true,
5
+ htmlWhitespaceSensitivity: 'css',
6
+ insertPragma: false,
7
+ bracketSameLine: false,
8
+ jsxSingleQuote: false,
9
+ printWidth: 120,
10
+ proseWrap: 'preserve',
11
+ quoteProps: 'as-needed',
12
+ requirePragma: false,
13
+ semi: true,
14
+ singleQuote: true,
15
+ tabWidth: 2,
16
+ trailingComma: 'none',
17
+ useTabs: false
18
+ };
@@ -0,0 +1,3 @@
1
+ import { deleteSync } from 'del';
2
+
3
+ deleteSync('./dist');
package/src/index.ts ADDED
@@ -0,0 +1,186 @@
1
+ import type { LitElement, ReactiveController, ReactiveControllerHost } from 'lit';
2
+
3
+ export type FunctionParams<T> = T extends (...args: infer U) => string ? U : [];
4
+
5
+ export interface Translation {
6
+ $code: string; // e.g. en, en-GB
7
+ $name: string; // e.g. English, Español
8
+ $dir: 'ltr' | 'rtl';
9
+ }
10
+
11
+ export interface ExistsOptions {
12
+ lang: string;
13
+ includeDefault: boolean;
14
+ }
15
+
16
+ const connectedElements = new Set<HTMLElement>();
17
+ const documentElementObserver = new MutationObserver(update);
18
+ const translations: Map<string, Translation> = new Map();
19
+ let documentDirection = document.documentElement.dir || 'ltr';
20
+ let documentLanguage = document.documentElement.lang || navigator.language;
21
+ let defaultTranslation: Translation;
22
+
23
+ // Watch for changes on <html lang>
24
+ documentElementObserver.observe(document.documentElement, {
25
+ attributes: true,
26
+ attributeFilter: ['dir', 'lang']
27
+ });
28
+
29
+ /** Registers the default (fallback) translation. */
30
+ export function registerDefaultTranslation(translation: Translation) {
31
+ defaultTranslation = translation;
32
+ update();
33
+ }
34
+
35
+ /** Registers one or more translations */
36
+ export function registerTranslation(...translation: Translation[]) {
37
+ translation.map(t => {
38
+ const code = t.$code.toLowerCase();
39
+
40
+ if (translations.has(code)) {
41
+ // Merge translations that share the same language code
42
+ translations.set(code, { ...translations.get(code), ...t });
43
+ } else {
44
+ translations.set(code, t);
45
+ }
46
+ });
47
+
48
+ update();
49
+ }
50
+
51
+ /** Updates all localized elements that are currently connected */
52
+ export function update() {
53
+ documentDirection = document.documentElement.dir || 'ltr';
54
+ documentLanguage = document.documentElement.lang || navigator.language;
55
+
56
+ [...connectedElements.keys()].map((el: LitElement) => {
57
+ if (typeof el.requestUpdate === 'function') {
58
+ el.requestUpdate();
59
+ }
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Localize Reactive Controller for components built with Lit
65
+ *
66
+ * To use this controller, import the class and instantiate it in a custom element constructor:
67
+ *
68
+ * private localize = new Localize(this);
69
+ *
70
+ * This will add the element to the set and make it respond to changes to <html dir|lang> automatically. To make it
71
+ * respond to changes to its own dir|lang properties, make it a property:
72
+ *
73
+ * @property() dir: string;
74
+ * @property() lang: string;
75
+ *
76
+ * To use a translation method, call it like this:
77
+ *
78
+ * ${this.localize.term('term_key_here')}
79
+ * ${this.localize.date('2021-12-03')}
80
+ * ${this.localize.number(1000000)}
81
+ */
82
+ export class Localize<UserTranslation extends Translation> implements ReactiveController {
83
+ host: ReactiveControllerHost & HTMLElement;
84
+
85
+ constructor(host: ReactiveControllerHost & HTMLElement) {
86
+ this.host = host;
87
+ this.host.addController(this);
88
+ }
89
+
90
+ hostConnected() {
91
+ connectedElements.add(this.host);
92
+ }
93
+
94
+ hostDisconnected() {
95
+ connectedElements.delete(this.host);
96
+ }
97
+
98
+ /**
99
+ * Gets the host element's directionality as determined by the `dir` attribute. The return value is transformed to
100
+ * lowercase.
101
+ */
102
+ dir() {
103
+ return `${this.host.dir || documentDirection}`.toLowerCase();
104
+ }
105
+
106
+ /**
107
+ * Gets the host element's language as determined by the `lang` attribute. The return value is transformed to
108
+ * lowercase.
109
+ */
110
+ lang() {
111
+ return `${this.host.lang || documentLanguage}`.toLowerCase();
112
+ }
113
+
114
+ private getTranslationData(lang: string) {
115
+ // Convert "en_US" to "en-US". Note that both underscores and dashes are allowed per spec, but underscores result in
116
+ // a RangeError by the call to `new Intl.Locale()`. See: https://unicode.org/reports/tr35/#unicode-locale-identifier
117
+ const locale = new Intl.Locale(lang.replace(/_/g, '-'));
118
+ const language = locale?.language.toLowerCase();
119
+ const region = locale?.region?.toLowerCase() ?? '';
120
+ const primary = <UserTranslation>translations.get(`${language}-${region}`);
121
+ const secondary = <UserTranslation>translations.get(language);
122
+
123
+ return { locale, language, region, primary, secondary };
124
+ }
125
+
126
+ /** Determines if the specified term exists, optionally checking the default translation. */
127
+ exists<K extends keyof UserTranslation>(key: K, options: Partial<ExistsOptions>): boolean {
128
+ const { primary, secondary } = this.getTranslationData(options.lang ?? this.lang());
129
+
130
+ options = {
131
+ includeDefault: false,
132
+ ...options
133
+ };
134
+
135
+ if (
136
+ (primary && primary[key]) ||
137
+ (secondary && secondary[key]) ||
138
+ (options.includeDefault && defaultTranslation && defaultTranslation[key as keyof Translation])
139
+ ) {
140
+ return true;
141
+ }
142
+
143
+ return false;
144
+ }
145
+
146
+ /** Outputs a translated term. */
147
+ term<K extends keyof UserTranslation>(key: K, ...args: FunctionParams<UserTranslation[K]>): string {
148
+ const { primary, secondary } = this.getTranslationData(this.lang());
149
+ let term: any;
150
+
151
+ // Look for a matching term using regionCode, code, then fallback to the default
152
+ if (primary && primary[key]) {
153
+ term = primary[key];
154
+ } else if (secondary && secondary[key]) {
155
+ term = secondary[key];
156
+ } else if (defaultTranslation && defaultTranslation[key as keyof Translation]) {
157
+ term = defaultTranslation[key as keyof Translation];
158
+ } else {
159
+ console.error(`No translation found for: ${String(key)}`);
160
+ return String(key);
161
+ }
162
+
163
+ if (typeof term === 'function') {
164
+ return term(...args) as string;
165
+ }
166
+
167
+ return term;
168
+ }
169
+
170
+ /** Outputs a localized date in the specified format. */
171
+ date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions): string {
172
+ dateToFormat = new Date(dateToFormat);
173
+ return new Intl.DateTimeFormat(this.lang(), options).format(dateToFormat);
174
+ }
175
+
176
+ /** Outputs a localized number in the specified format. */
177
+ number(numberToFormat: number | string, options?: Intl.NumberFormatOptions): string {
178
+ numberToFormat = Number(numberToFormat);
179
+ return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(this.lang(), options).format(numberToFormat);
180
+ }
181
+
182
+ /** Outputs a localized time in relative format. */
183
+ relativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string {
184
+ return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
185
+ }
186
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "compilerOptions": {
3
+ /* Visit https://aka.ms/tsconfig.json to read more about this file */
4
+
5
+ /* Basic Options */
6
+ // "incremental": true /* Enable incremental compilation */,
7
+ "target": "es2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
8
+ "module": "es2015" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
9
+ "lib": [ /* Specify library files to be included in the compilation. */
10
+ "dom",
11
+ "dom.Iterable",
12
+ "es2020"
13
+ ],
14
+ // "allowJs": true, /* Allow javascript files to be compiled. */
15
+ // "checkJs": true, /* Report errors in .js files. */
16
+ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
17
+ "declaration": true /* Generates corresponding '.d.ts' file. */,
18
+ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
19
+ // "sourceMap": true /* Generates corresponding '.map' file. */,
20
+ // "outFile": "./", /* Concatenate and emit output to single file. */
21
+ "outDir": "./dist" /* Redirect output structure to the directory. */,
22
+ "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
23
+ // "composite": true, /* Enable project compilation */
24
+ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
25
+ // "removeComments": true, /* Do not emit comments to output. */
26
+ // "noEmit": true, /* Do not emit outputs. */
27
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
28
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
29
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
30
+
31
+ /* Strict Type-Checking Options */
32
+ // "strict": true, /* Enable all strict type-checking options. */
33
+ "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
34
+ "strictNullChecks": true /* Enable strict null checks. */,
35
+ // "strictFunctionTypes": true, /* Enable strict checking of function types. */
36
+ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
37
+ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
38
+ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
39
+ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
40
+
41
+ /* Additional Checks */
42
+ "noUnusedLocals": true /* Report errors on unused locals. */,
43
+ "noUnusedParameters": true /* Report errors on unused parameters. */,
44
+ "noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
45
+ "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
46
+ // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
47
+
48
+ /* Module Resolution Options */
49
+ "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
50
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
51
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
52
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
53
+ // "typeRoots": [], /* List of folders to include type definitions from. */
54
+ // "types": [], /* Type declaration files to be included in compilation. */
55
+ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
56
+ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
57
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
58
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
59
+
60
+ /* Source Map Options */
61
+ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
62
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
63
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
64
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
65
+
66
+ /* Experimental Options */
67
+ "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
68
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
69
+
70
+ /* Advanced Options */
71
+ "removeComments": true,
72
+ "skipLibCheck": true /* Skip type checking of declaration files. */,
73
+ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
74
+ },
75
+ "include": [
76
+ "src/**/*.ts"
77
+ ]
78
+ }