From 4756ddd778d5081d0dbffdf99cb6cef9d4910514 Mon Sep 17 00:00:00 2001 From: Danny Gleckler Date: Wed, 21 Aug 2024 17:31:08 -0400 Subject: [PATCH 1/4] Move to BrightspaceUI/testing --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bcb61e9..379f572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -305,9 +305,9 @@ } }, "node_modules/@brightspace-ui/intl": { - "version": "3.16.1", - "resolved": "https://registry.npmjs.org/@brightspace-ui/intl/-/intl-3.16.1.tgz", - "integrity": "sha512-KsoUKvy4LFkW9XQeTbTf5G9DyMZeFwDIDXh1LmaKLYBCJpVbUznzG+ZLxv3xrmB31lQlZF4fnP6ouN1uoJIz5w==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@brightspace-ui/intl/-/intl-3.16.2.tgz", + "integrity": "sha512-1KF96QoXFujmfzaJE/s98LDDDOU/YlWmiZnhh5M71RnmyDD5MWlE+dinrHZi/TveCoMcUNVSjxkgTka5FKL4jA==", "dev": true }, "node_modules/@brightspace-ui/testing": { From 89d0a074f4feefe217a4cb13c600e120e76ad39d Mon Sep 17 00:00:00 2001 From: Danny Gleckler Date: Wed, 21 Aug 2024 18:17:16 -0400 Subject: [PATCH 2/4] Add localize --- helpers/getLocalizeResources.js | 408 ++++++++++++++++++++++++++++++++ lib/localize.js | 255 ++++++++++++++++++++ package-lock.json | 76 +++++- package.json | 7 +- test/localize.test.js | 94 ++++++++ 5 files changed, 837 insertions(+), 3 deletions(-) create mode 100644 helpers/getLocalizeResources.js create mode 100644 lib/localize.js create mode 100644 test/localize.test.js diff --git a/helpers/getLocalizeResources.js b/helpers/getLocalizeResources.js new file mode 100644 index 0000000..70ee2fb --- /dev/null +++ b/helpers/getLocalizeResources.js @@ -0,0 +1,408 @@ +import { getDocumentLocaleSettings } from '../lib/common.js'; + +const CacheName = 'd2l-oslo'; +const ContentTypeHeader = 'Content-Type'; +const ContentTypeJson = 'application/json'; +const DebounceTime = 150; +const ETagHeader = 'ETag'; +const StateFetching = 2; +const StateIdle = 1; + +const BatchFailedReason = new Error('Failed to fetch batch overrides.'); +const SingleFailedReason = new Error('Failed to fetch overrides.'); + +const blobs = new Map(); + +let cache = undefined; +let cachePromise = undefined; +let documentLocaleSettings = undefined; +let queue = []; +let state = StateIdle; +let timer = 0; +let debug = false; + +async function publish(request, response) { + + if (response.ok) { + const overridesJson = await response.json(); + request.resolve(overridesJson); + } else { + request.reject(SingleFailedReason); + } +} + +async function flushQueue() { + + timer = 0; + state = StateFetching; + + if (queue.length <= 0) { + state = StateIdle; + return; + } + + const requests = queue; + + queue = []; + + const resources = requests.map(item => item.resource); + const bodyObject = { resources }; + const bodyText = JSON.stringify(bodyObject); + + const res = await fetch(documentLocaleSettings.oslo.batch, { + method: 'POST', + body: bodyText, + headers: { [ContentTypeHeader]: ContentTypeJson } + }); + + if (res.ok) { + + const responses = (await res.json()).resources; + + const tasks = []; + + for (let i = 0; i < responses.length; ++i) { + + const response = responses[i]; + const request = requests[i]; + + const responseValue = new Response(response.body, { + status: response.status, + headers: response.headers + }); + + // New version might be available since the page loaded, so make a + // record of it. + + const nextVersion = responseValue.headers.get(ETagHeader); + if (nextVersion) { + setVersion(nextVersion); + } + + const cacheKey = new Request(formatCacheKey(request.resource)); + const cacheValue = responseValue.clone(); + + if (cache === undefined) { + if (cachePromise === undefined) { + cachePromise = caches.open(CacheName); + } + cache = await cachePromise; + } + + debug && console.log(`[Oslo] cache prime: ${request.resource}`); + tasks.push(cache.put(cacheKey, cacheValue)); + tasks.push(publish(request, responseValue)); + } + + await Promise.all(tasks); + + } else { + + for (const request of requests) { + + request.reject(BatchFailedReason); + } + } + + if (queue.length > 0) { + setTimeout(flushQueue, 0); + } else { + state = StateIdle; + } +} + +function debounceQueue() { + + if (state !== StateIdle) { + return; + } + + if (timer > 0) { + clearTimeout(timer); + } + + timer = setTimeout(flushQueue, DebounceTime); +} + +async function fetchCollection(url) { + + if (blobs.has(url)) { + return Promise.resolve(blobs.get(url)); + } + + const res = await fetch(url, { method: 'GET' }); + + if (res.ok) { + const resJson = await res.json(); + blobs.set(url, resJson); + return Promise.resolve(resJson); + } else { + return Promise.reject(SingleFailedReason); + } +} + +function fetchWithQueuing(resource) { + + const promise = new Promise((resolve, reject) => { + + queue.push({ resource, resolve, reject }); + }); + + debounceQueue(); + + return promise; +} + +function formatCacheKey(resource) { + + return formatOsloRequest(documentLocaleSettings.oslo.collection, resource); +} + +async function fetchWithCaching(resource) { + + if (cache === undefined) { + if (cachePromise === undefined) { + cachePromise = caches.open(CacheName); + } + cache = await cachePromise; + } + + const cacheKey = new Request(formatCacheKey(resource)); + const cacheValue = await cache.match(cacheKey); + if (cacheValue === undefined) { + debug && console.log(`[Oslo] cache miss: ${resource}`); + return fetchWithQueuing(resource); + } + + debug && console.log(`[Oslo] cache hit: ${resource}`); + if (!cacheValue.ok) { + fetchWithQueuing(resource).then(url => URL.revokeObjectURL(url)); + throw SingleFailedReason; + } + + // Check if the cache response is stale based on either the document init or + // any requests we've made to the LMS since init. We'll still serve stale + // from cache for this page, but we'll update it in the background for the + // next page. + + // We rely on the ETag header to identify if the cache needs to be updated. + // The LMS will provide it in the format: [release].[build].[langModifiedVersion] + // So for example, an ETag in the 20.20.10 release could be: 20.20.10.24605.55520 + + const currentVersion = getVersion(); + if (currentVersion) { + + const previousVersion = cacheValue.headers.get(ETagHeader); + if (previousVersion !== currentVersion) { + + debug && console.log(`[Oslo] cache stale: ${resource}`); + fetchWithQueuing(resource).then(url => URL.revokeObjectURL(url)); + } + } + + return await cacheValue.json(); +} + +function fetchWithPooling(resource) { + + // At most one request per resource. + + let promise = blobs.get(resource); + if (promise === undefined) { + promise = fetchWithCaching(resource); + blobs.set(resource, promise); + } + return promise; +} + +async function shouldUseBatchFetch() { + + if (documentLocaleSettings === undefined) { + documentLocaleSettings = getDocumentLocaleSettings(); + } + + if (!documentLocaleSettings.oslo) { + return false; + } + + try { + + // try opening CacheStorage, if the session is in a private browser in firefox this throws an exception + await caches.open(CacheName); + + // Only batch if we can do client-side caching, otherwise it's worse on each + // subsequent page navigation. + + return Boolean(documentLocaleSettings.oslo.batch) && 'CacheStorage' in window; + } catch (err) { + return false; + } + +} + +function shouldUseCollectionFetch() { + + if (documentLocaleSettings === undefined) { + documentLocaleSettings = getDocumentLocaleSettings(); + } + + if (!documentLocaleSettings.oslo) { + return false; + } + + return Boolean(documentLocaleSettings.oslo.collection); +} + +function setVersion(version) { + + if (documentLocaleSettings === undefined) { + documentLocaleSettings = getDocumentLocaleSettings(); + } + + if (!documentLocaleSettings.oslo) { + return; + } + + documentLocaleSettings.oslo.version = version; +} + +function getVersion() { + + if (documentLocaleSettings === undefined) { + documentLocaleSettings = getDocumentLocaleSettings(); + } + + const shouldReturnVersion = + documentLocaleSettings.oslo && + documentLocaleSettings.oslo.version; + if (!shouldReturnVersion) { + return null; + } + + return documentLocaleSettings.oslo.version; +} + +async function shouldFetchOverrides() { + + const isOsloAvailable = + await shouldUseBatchFetch() || + shouldUseCollectionFetch(); + + return isOsloAvailable; +} + +async function fetchOverride(formatFunc) { + + let resource, res, requestURL; + + if (await shouldUseBatchFetch()) { + + // If batching is available, pool requests together. + + resource = formatFunc(); + res = fetchWithPooling(resource); + + } else /* shouldUseCollectionFetch() == true */ { + + // Otherwise, fetch it directly and let the LMS manage the cache. + + resource = formatFunc(); + requestURL = formatOsloRequest(documentLocaleSettings.oslo.collection, resource); + + res = fetchCollection(requestURL); + + } + res = res.catch(coalesceToNull); + return res; +} + +function coalesceToNull() { + + return null; +} + +function formatOsloRequest(baseUrl, resource) { + return `${baseUrl}/${resource}`; +} + +export function __clearWindowCache() { + + // Used to reset state for tests. + + blobs.clear(); + cache = undefined; + cachePromise = undefined; +} + +export function __enableDebugging() { + + // Used to enable debug logging during development. + + debug = true; +} + +export async function getLocalizeOverrideResources( + langCode, + translations, + formatFunc +) { + const promises = []; + + promises.push(translations); + + if (await shouldFetchOverrides()) { + const overrides = await fetchOverride(formatFunc); + promises.push(overrides); + } + + const results = await Promise.all(promises); + + return { + language: langCode, + resources: Object.assign({}, ...results) + }; +} + +export async function getLocalizeResources( + possibleLanguages, + filterFunc, + formatFunc, + fetchFunc +) { + + const promises = []; + let supportedLanguage; + + if (await shouldFetchOverrides()) { + + const overrides = await fetchOverride(formatFunc, fetchFunc); + promises.push(overrides); + } + + for (const language of possibleLanguages) { + + if (filterFunc(language)) { + + if (supportedLanguage === undefined) { + supportedLanguage = language; + } + + const translations = fetchFunc(formatFunc(language)); + promises.push(translations); + + break; + } + } + + const results = await Promise.all(promises); + + // We're fetching in best -> worst, so we'll assign worst -> best, so the + // best overwrite everything else. + + results.reverse(); + + return { + language: supportedLanguage, + resources: Object.assign({}, ...results) + }; +} diff --git a/lib/localize.js b/lib/localize.js new file mode 100644 index 0000000..709509d --- /dev/null +++ b/lib/localize.js @@ -0,0 +1,255 @@ +import '@formatjs/intl-pluralrules/dist-es6/polyfill-locales.js'; +import { defaultLocale as fallbackLang, getDocumentLocaleSettings, supportedLangpacks } from '../lib/common.js'; +import { getLocalizeOverrideResources } from '../helpers/getLocalizeResources.js'; +import IntlMessageFormat from 'intl-messageformat'; + +export const allowedTags = Object.freeze(['d2l-link', 'd2l-tooltip-help', 'p', 'br', 'b', 'strong', 'i', 'em', 'button']); + +const getDisallowedTagsRegex = allowedTags => { + const validTerminators = '([>\\s/]|$)'; + const allowedAfterTriangleBracket = `/?(${allowedTags.join('|')})?${validTerminators}`; + return new RegExp(`<(?!${allowedAfterTriangleBracket})`); +}; + +export const disallowedTagsRegex = getDisallowedTagsRegex(allowedTags); +const noAllowedTagsRegex = getDisallowedTagsRegex([]); + +export const getLocalizeClass = (superclass = class {}) => class LocalizeClass extends superclass { + + static #localizeMarkup; + static documentLocaleSettings = getDocumentLocaleSettings(); + + static setLocalizeMarkup(localizeMarkup) { + this.#localizeMarkup ??= localizeMarkup; + } + + #connected = false; + #localeChangeCallback; + #resolveResourcesLoaded; + #resourcesPromise; + pristine = true; + + async #localeChangeHandler() { + if (!this._hasResources()) return; + + const resourcesPromise = this.constructor._getAllLocalizeResources(this.config); + this.#resourcesPromise = resourcesPromise; + const localizeResources = (await resourcesPromise).flat(Infinity); + // If the locale changed while resources were being fetched, abort + if (this.#resourcesPromise !== resourcesPromise) return; + + const allResources = {}; + const resolvedLocales = new Set(); + for (const { language, resources } of localizeResources) { + for (const [key, value] of Object.entries(resources)) { + allResources[key] = { language, value }; + resolvedLocales.add(language); + } + } + this.localize.resources = allResources; + this.localize.resolvedLocale = [...resolvedLocales][0]; + if (resolvedLocales.size > 1) { + console.warn(`Resolved multiple locales in '${this.constructor.name || this.tagName || ''}': ${[...resolvedLocales].join(', ')}`); + } + + if (this.pristine) { + this.pristine = false; + this.#resolveResourcesLoaded(); + } + + this.#onResourcesChange(); + } + + #onResourcesChange() { + if (this.#connected) { + this.dispatchEvent?.(new CustomEvent('d2l-localize-resources-change')); + this.config?.onResourcesChange?.(); + this.onLocalizeResourcesChange?.(); + } + } + + connect() { + this.#localeChangeCallback = () => this.#localeChangeHandler(); + LocalizeClass.documentLocaleSettings.addChangeListener(this.#localeChangeCallback); + this.#connected = true; + this.#localeChangeCallback(); + } + + disconnect() { + LocalizeClass.documentLocaleSettings.removeChangeListener(this.#localeChangeCallback); + this.#connected = false; + } + + localize(key) { + + const { language, value } = this.localize.resources?.[key] ?? {}; + if (!value) return ''; + + let params = {}; + if (arguments.length > 1 && arguments[1]?.constructor === Object) { + // support for key-value replacements as a single arg + params = arguments[1]; + } else { + // legacy support for localize-behavior replacements as many args + for (let i = 1; i < arguments.length; i += 2) { + params[arguments[i]] = arguments[i + 1]; + } + } + + const translatedMessage = new IntlMessageFormat(value, language); + let formattedMessage = value; + try { + validateMarkup(formattedMessage, noAllowedTagsRegex); + formattedMessage = translatedMessage.format(params); + } catch (e) { + if (e.name === 'MarkupError') { + e = new Error('localize() does not support rich text. For more information, see: https://github.com/BrightspaceUI/core/blob/main/mixins/localize/'); // eslint-disable-line no-ex-assign + formattedMessage = ''; + } + console.error(e); + } + + return formattedMessage; + } + + localizeHTML(key, params = {}) { + + const { language, value } = this.localize.resources?.[key] ?? {}; + if (!value) return ''; + + const translatedMessage = new IntlMessageFormat(value, language); + let formattedMessage = value; + try { + const unvalidated = translatedMessage.format({ + b: chunks => LocalizeClass.#localizeMarkup`${chunks}`, + br: () => LocalizeClass.#localizeMarkup`
`, + em: chunks => LocalizeClass.#localizeMarkup`${chunks}`, + i: chunks => LocalizeClass.#localizeMarkup`${chunks}`, + p: chunks => LocalizeClass.#localizeMarkup`

${chunks}

`, + strong: chunks => LocalizeClass.#localizeMarkup`${chunks}`, + ...params + }); + validateMarkup(unvalidated); + formattedMessage = unvalidated; + } catch (e) { + if (e.name === 'MarkupError') formattedMessage = ''; + console.error(e); + } + + return formattedMessage; + } + + __resourcesLoadedPromise = new Promise(r => this.#resolveResourcesLoaded = r); + + static _generatePossibleLanguages(config) { + + if (config?.useBrowserLangs) return navigator.languages.map(e => e.toLowerCase()).concat('en'); + + const { language, fallbackLanguage } = this.documentLocaleSettings; + const langs = [ language, fallbackLanguage ] + .filter(e => e) + .map(e => [ e.toLowerCase(), e.split('-')[0] ]) + .flat(); + + return Array.from(new Set([ ...langs, 'en-us', 'en' ])); + } + + static _getAllLocalizeResources(config = this.localizeConfig) { + const resourcesLoadedPromises = []; + const superCtor = Object.getPrototypeOf(this); + // get imported terms for each config, head up the chain to get them all + if ('_getAllLocalizeResources' in superCtor) { + const superConfig = Object.prototype.hasOwnProperty.call(superCtor, 'localizeConfig') && superCtor.localizeConfig.importFunc ? superCtor.localizeConfig : config; + resourcesLoadedPromises.push(superCtor._getAllLocalizeResources(superConfig)); + } + if (Object.prototype.hasOwnProperty.call(this, 'getLocalizeResources') || Object.prototype.hasOwnProperty.call(this, 'resources')) { + const possibleLanguages = this._generatePossibleLanguages(config); + const resourcesPromise = this.getLocalizeResources(possibleLanguages, config); + resourcesLoadedPromises.push(resourcesPromise); + } + return Promise.all(resourcesLoadedPromises); + } + + static async _getLocalizeResources(langs, { importFunc, osloCollection, useBrowserLangs }) { + + // in dev, don't request unsupported langpacks + if (!importFunc.toString().includes('switch') && !useBrowserLangs) { + langs = langs.filter(lang => supportedLangpacks.includes(lang)); + } + + for (const lang of [...langs, fallbackLang]) { + + const resources = await Promise.resolve(importFunc(lang)).catch(() => {}); + + if (resources) { + + if (osloCollection) { + return await getLocalizeOverrideResources( + lang, + resources, + () => osloCollection + ); + } + + return { + language: lang, + resources + }; + } + } + } + + _hasResources() { + return this.constructor.localizeConfig ? Boolean(this.constructor.localizeConfig.importFunc) : this.constructor.getLocalizeResources !== undefined; + } + +}; + +export const Localize = class extends getLocalizeClass() { + + static getLocalizeResources() { + return super._getLocalizeResources(...arguments); + } + + constructor(config) { + super(); + super.constructor.setLocalizeMarkup(localizeMarkup); + this.config = config; + this.connect(); + } + + get ready() { + return this.__resourcesLoadedPromise; + } + + connect() { + super.connect(); + return this.ready; + } + +}; + +class MarkupError extends Error { + name = this.constructor.name; +} + +export function validateMarkup(content, disallowedTagsRegex) { + if (content) { + if (content.forEach) { + content.forEach(item => validateMarkup(item)); + return; + } + if (content._localizeMarkup) return; + if (Object.hasOwn(content, '_$litType$')) throw new MarkupError('Rich-text replacements must use localizeMarkup templates. For more information, see: https://github.com/BrightspaceUI/core/blob/main/mixins/localize/'); + + if (content.constructor === String && disallowedTagsRegex?.test(content)) throw new MarkupError(`Rich-text replacements may use only the following allowed elements: ${allowedTags}. For more information, see: https://github.com/BrightspaceUI/core/blob/main/mixins/localize/`); + } +} + +export function localizeMarkup(strings, ...expressions) { + strings.forEach(str => validateMarkup(str, disallowedTagsRegex)); + expressions.forEach(exp => validateMarkup(exp, disallowedTagsRegex)); + return strings.reduce((acc, i, idx) => { + return acc.push(i, expressions[idx] ?? '') && acc; + }, []).join(''); +} diff --git a/package-lock.json b/package-lock.json index 379f572..0ae9d58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "@brightspace-ui/intl", "version": "3.16.2", "license": "Apache-2.0", + "dependencies": { + "@formatjs/intl-pluralrules": "^1", + "intl-messageformat": "^10" + }, "devDependencies": { "@brightspace-ui/testing": "^1", "eslint": "^8", @@ -488,6 +492,64 @@ "@types/chai": "^4.2.12" } }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz", + "integrity": "sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==", + "dependencies": { + "@formatjs/intl-localematcher": "0.5.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.0.tgz", + "integrity": "sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.7.8", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.8.tgz", + "integrity": "sha512-nBZJYmhpcSX0WeJ5SDYUkZ42AgR3xiyhNCsQweFx3cz/ULJjym8bHAzWKvG5e2+1XO98dBYC0fWeeAECAVSwLA==", + "dependencies": { + "@formatjs/ecma402-abstract": "2.0.0", + "@formatjs/icu-skeleton-parser": "1.8.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.2.tgz", + "integrity": "sha512-k4ERKgw7aKGWJZgTarIcNEmvyTVD9FYh0mTrrBMHZ1b8hUu6iOJ4SzsZlo3UNAvHYa+PnvntIwRPt1/vy4nA9Q==", + "dependencies": { + "@formatjs/ecma402-abstract": "2.0.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz", + "integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/intl-pluralrules": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/@formatjs/intl-pluralrules/-/intl-pluralrules-1.5.9.tgz", + "integrity": "sha512-37E1ZG+Oqo3qrpUfumzNcFTV+V+NCExmTkkQ9Zw4FSlvJ4WhbbeYdieVapUVz9M0cLy8XrhCkfuM/Kn03iKReg==", + "dependencies": { + "@formatjs/intl-utils": "^2.3.0" + } + }, + "node_modules/@formatjs/intl-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz", + "integrity": "sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==", + "deprecated": "the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package" + }, "node_modules/@hapi/bourne": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", @@ -5094,6 +5156,17 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "10.5.14", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.5.14.tgz", + "integrity": "sha512-IjC6sI0X7YRjjyVH9aUgdftcmZK7WXdHeil4KwbjDnRWjnVitKpAx3rr6t6di1joFp5188VqKcobOPA6mCLG/w==", + "dependencies": { + "@formatjs/ecma402-abstract": "2.0.0", + "@formatjs/fast-memoize": "2.2.0", + "@formatjs/icu-messageformat-parser": "2.7.8", + "tslib": "^2.4.0" + } + }, "node_modules/ip-address": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", @@ -8414,8 +8487,7 @@ "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/tsscmp": { "version": "1.0.6", diff --git a/package.json b/package.json index 2da27e5..6e3eb5a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "test": "npm run lint && npm run test:unit" }, "files": [ - "/lib" + "/lib", + "/helpers" ], "publishConfig": { "access": "public" @@ -40,5 +41,9 @@ "@brightspace-ui/testing": "^1", "eslint": "^8", "eslint-config-brightspace": "^1" + }, + "dependencies": { + "@formatjs/intl-pluralrules": "^1", + "intl-messageformat": "^10" } } diff --git a/test/localize.test.js b/test/localize.test.js new file mode 100644 index 0000000..e9ba0a2 --- /dev/null +++ b/test/localize.test.js @@ -0,0 +1,94 @@ +import { expect, fixture } from '@brightspace-ui/testing'; +import { Localize, localizeMarkup } from '../lib/localize.js'; + +const resources = { + en: { + basic: '{employerName} is my employer', + many: 'This {type} has {count} arguments', + html: 'Wrapped in tags' + }, + 'en-gb': { + basic: '{employerName} is my employer, but British!' + } +}; + +describe('Localize', () => { + + let localizer, runCount, updatePromise; + beforeEach(async() => { + await fixture('
'); + runCount = 0; + + let resolve; + updatePromise = new Promise(r => resolve = r); + + localizer = {}; + localizer = new Localize({ + importFunc: async lang => await new Promise(r => setTimeout(() => r(resources[lang]), 1)), + onResourcesChange: () => { + if (runCount) resolve(); + runCount++; + } + }); + expect(runCount).to.equal(0); + }); + + afterEach(() => { + localizer.disconnect(); + }); + + it('should not be set up before ready', async() => { + expect(runCount).to.equal(0); + expect(localizer.localize.resources).to.be.undefined; + expect(localizer.localize.resolvedLocale).to.be.undefined; + expect(localizer.pristine).to.be.true; + }); + + it('should have been set up when ready', async() => { + await localizer.ready; + expect(runCount).to.equal(1); + expect(localizer.localize.resources).to.be.an('object'); + expect(localizer.localize.resolvedLocale).to.equal('en'); + expect(localizer.pristine).to.be.false; + }); + + describe('onResourcesChange', () => { + + it('runs when the document locale changes', async() => { + await localizer.ready; + expect(localizer.localize.resolvedLocale).to.equal('en'); + document.documentElement.lang = 'en-gb'; + await updatePromise; + expect(runCount).to.equal(2); + expect(localizer.localize.resolvedLocale).to.equal('en-gb'); + }); + + }); + + describe('localize()', () => { + + it('should localize text', async() => { + await localizer.ready; + const localized = localizer.localize('basic', { employerName: 'D2L' }); + expect(localized).to.equal('D2L is my employer'); + }); + + it('should accept exapnded/spread params', async() => { + await localizer.ready; + const localized = localizer.localize('many', 'type', 'message', 'count', 2); + expect(localized).to.equal('This message has 2 arguments'); + }); + + }); + + describe('localizeHTML()', () => { + + it('should localize, replacing tags with HTML', async() => { + await localizer.ready; + const localized = localizer.localizeHTML('html', { paragraph: chunks => localizeMarkup`

${chunks}

` }); + expect(localized).to.equal('

Wrapped in tags

'); + }); + + }); + +}); From b89101b61342b35538604e6bd046896e853330d6 Mon Sep 17 00:00:00 2001 From: Danny Gleckler Date: Thu, 22 Aug 2024 11:29:52 -0400 Subject: [PATCH 3/4] Fix lint --- .eslintrc.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.eslintrc.json b/.eslintrc.json index de270d7..0d51ff7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,5 +3,13 @@ "overrides": [{ "files": "test/*", "extends": "brightspace/testing-config" + }, + { + "files": [ + "helpers/getLocalizeResources.js" + ], + "rules": { + "no-console": 0 + } }] } From c945db6e7d301cac99a983b79f8059d4a5f50555 Mon Sep 17 00:00:00 2001 From: Danny Gleckler Date: Thu, 22 Aug 2024 14:46:19 -0400 Subject: [PATCH 4/4] Add localize to demo page --- demo/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/demo/index.html b/demo/index.html index ec2c12c..3c23697 100644 --- a/demo/index.html +++ b/demo/index.html @@ -22,8 +22,9 @@ import * as fileSize from '../lib/fileSize.js'; import * as list from '../lib/list.js'; import * as number from '../lib/number.js'; + import * as localize from '../lib/localize.js'; - window.intl = { ...common, ...dateTime, ...fileSize, ...list, ...number }; + window.intl = { ...common, ...dateTime, ...fileSize, ...list, ...number, ...localize };