shoelace-style / localize

A micro library for localizing custom elements.
MIT License
55 stars 6 forks source link

Getting rid of the depencency on Lit / etc. #5

Closed xdev1 closed 2 years ago

xdev1 commented 2 years ago

Frankly, I cannot see why the dependency on Lit is necessary for Localize. Localize could easily be a dependency-free library. The main problem is that the constructor of class LocalizeController is of type constructor(host: ReactiveControllerHost & HTMLElement) which is way to complicated (=> unnecessarily complicated). If a third-party SL component developer does NOT use LitElement (like me for example) it gets really ugly to use Localize (for example, if someone uses a hooks-based web component library - again: like me - and wants to implement a useLocalize hook based on LocalizeController).

It would be much easier for everybody to define an interface like the following (which is a super type of LitElement)

interface LocalizableHost {
  addController(controller: { hostConnected(): void; hostDisconnected(): void }): void;
  requestUpdate(): void;
  lang: string;
}

and use this as first argument of the LocalizeController constructor (LocalizableHost is a much less complicated type than ReactiveControllerHost & HTMLElement).

Also, I think the name updateLocalizedTerms is not a very good choice as this function does more than just updating terms. Maybe updateLocalization or whatever would be a better choice. If it's not clear what I mean: Please see the components sl-format-bytes, sl-format-date, sl-format-number, sl-relative-time in branch localize in the @shoelace-style/shoelace project. They are not yet based on Localize but sooner or later they will, as this is surely the standard SL way that they will properly be auto-updated on document language changes, and it's also useful from a software design POV, proper abstraction and consistency reasons. Then, those components will be auto-updated by implicit updateLocalizeTerms invocations, but as the mentioned components do not even have terms, so the name updateLocalizeTerms does not really always make sense. Q.E.D.

The following code has not been tested but I guess something like that should basically work (and produce smaller JavaScript code as the original version). ```typescript export interface LocalizableHost { addController(controller: { hostConnected(): void; hostDisconnected(): void }): void; requestUpdate(): void; lang: string; } export type FunctionParams = T extends (...args: infer U) => string ? U : never; export interface Translation { $code: string; // e.g. en, en-GB $name: string; // e.g. English, Español $dir: 'ltr' | 'rtl'; [key: string]: any; } const connectedElements = new Set(); const documentElementObserver = new MutationObserver(updateLocalization); const translations: Map = new Map(); let documentLanguage = document.documentElement.lang || navigator.language; let fallback: Translation; // Watch for changes on documentElementObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }); // // Registers one or more translations // export function registerTranslation(...translation: Translation[]) { translation.map(t => { const code = t.$code.toLowerCase(); translations.set(code, t); // The first translation that's registered is the fallback if (!fallback) { fallback = t; } }); updateLocalization(); } // // Translates a term using the specified locale. Looks up translations in order of language + country codes (es-PE), // language code (es), then the fallback translation. // export function term(lang: string, key: K, ...args: FunctionParams) { const code = lang.toLowerCase().slice(0, 2); // e.g. en const subcode = lang.length > 2 ? lang.toLowerCase() : ''; // e.g. en-GB const primary = translations.get(subcode); const secondary = translations.get(code); let term: any; // Look for a matching term using subcode, code, then the fallback if (primary && primary[key]) { term = primary[key]; } else if (secondary && secondary[key]) { term = secondary[key]; } else if (fallback && fallback[key]) { term = fallback[key]; } else { console.error(`No translation found for: ${key}`); return key; } if (typeof term === 'function') { return term(...args) as string; } return term; } // // Formats a date using the specified locale. // export function date(lang: string, dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions) { dateToFormat = new Date(dateToFormat); return new Intl.DateTimeFormat(lang, options).format(dateToFormat); } // // Formats a number using the specified locale. // export function number(lang: string, numberToFormat: number | string, options?: Intl.NumberFormatOptions) { numberToFormat = Number(numberToFormat); return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(lang, options).format(numberToFormat); } // // Updates the locale for all localized elements that are currently connected // export function updateLocalization() { documentLanguage = document.documentElement.lang || navigator.language; connectedElements.forEach(host => host.requestUpdate()); } // // Reactive controller // // To use this controller, import the class and instantiate it in a custom element constructor: // // private localize = new LocalizeController(this); // // This will add the element to the set and make it respond to changes to automatically. To make it respond // to changes to its own lang property, make it a property: // // @property() lang: string; // // To use a translation method, call it like this: // // ${this.localize.term('term_key_here')} // ${this.localize.date('2021-12-03')} // ${this.localize.number(1000000)} // export class LocalizeController { constructor(private host: LocalizableHost) { host.addController({ hostConnected: () => connectedElements.add(host), hostDisconnected: () => connectedElements.delete(host) }); } term(key: K, ...args: FunctionParams) { return term(this.host.lang || documentLanguage, key, ...args); } date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions) { return date(this.host.lang || documentLanguage, dateToFormat, options); } number(numberToFormat: number | string, options?: Intl.NumberFormatOptions) { return number(this.host.lang || documentLanguage, numberToFormat, options); } } ```
claviska commented 2 years ago

Frankly, I cannot see why the dependency on Lit is necessary for Localize.

It's not necessary. In fact, it's no longer a dependency. Lit is only a dev dependency for the purpose of typings.

I find the Reactive Controller pattern to be superior to the previous approach this library took, which relied on decorators and directives. With ~726 bytes (minified/gzipped), you get a complete component-level localization solution with zero dependencies.

The only requirement is whatever authoring library you use must support the Reactive Controller pattern — or you can shim it. There's already interest in the community to make this sort of a "standard" so it can more easily work with components built with other libraries as well.

The controversial statement in the previous paragraph is:

whatever authoring library you use must support the Reactive Controller pattern

I don't care if this means nobody else supports it today. This is an emerging pattern that I believe will become ubiquitous as more controllers are open sourced. It's a chicken/egg problem and a bet I'm willing to take.

It also satisfies my need for Shoelace very well, which is the main reason this library exists.

Also, I think the name updateLocalizedTerms is not a very good choice

Agree. I've renamed it to update in 7be109847d3b89277f84ee1087b353c5c6ad6d18.

Please see the components sl-format-bytes, sl-format-date, sl-format-number, sl-relative-time in branch localize in the @shoelace-style/shoelace project. They are not yet based on Localize but sooner or later they will

They are now. https://github.com/shoelace-style/shoelace/commit/f87cb8d940e447a9ae0a2419a628015644856875

Closing since the dependency has been gotten rid of.

xdev1 commented 2 years ago

Many thanks :smiley::+1: