From c2ffb304310a7353c8436fc6c1154d6684ec9fae Mon Sep 17 00:00:00 2001 From: Kiwi Date: Sat, 13 Oct 2018 01:16:13 +0200 Subject: [PATCH] [next] i18n short number support (#1992) * feat: Implement i18n short number * test: fix failing tests --- babel.config.js | 17 ++++ config/jest/client.config.js | 3 +- package-lock.json | 6 +- package.json | 1 + .../lib/i18n/functions/SHORT_NUMBER.ts | 10 +++ .../framework/lib/i18n/functions/index.ts | 1 + .../lib/{i18n.ts => i18n/generateBundles.ts} | 50 +----------- src/core/client/framework/lib/i18n/index.ts | 3 + src/core/client/framework/lib/i18n/locales.ts | 19 +++++ .../framework/lib/i18n/negotiateLanguages.ts | 27 +++++++ .../lib/i18n/types/FluentShortNumber.spec.ts | 33 ++++++++ .../lib/i18n/types/FluentShortNumber.ts | 81 +++++++++++++++++++ .../client/framework/lib/i18n/types/index.ts | 1 + .../testHelpers/createFluentBundle.ts | 6 +- src/locales/en-US/framework.ftl | 13 +++ src/locales/en-US/stream.ftl | 2 +- src/types/fluent.d.ts | 17 +++- 17 files changed, 235 insertions(+), 55 deletions(-) create mode 100644 babel.config.js create mode 100644 src/core/client/framework/lib/i18n/functions/SHORT_NUMBER.ts create mode 100644 src/core/client/framework/lib/i18n/functions/index.ts rename src/core/client/framework/lib/{i18n.ts => i18n/generateBundles.ts} (55%) create mode 100644 src/core/client/framework/lib/i18n/index.ts create mode 100644 src/core/client/framework/lib/i18n/locales.ts create mode 100644 src/core/client/framework/lib/i18n/negotiateLanguages.ts create mode 100644 src/core/client/framework/lib/i18n/types/FluentShortNumber.spec.ts create mode 100644 src/core/client/framework/lib/i18n/types/FluentShortNumber.ts create mode 100644 src/core/client/framework/lib/i18n/types/index.ts diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 000000000..384a0ea5a --- /dev/null +++ b/babel.config.js @@ -0,0 +1,17 @@ +/** + * This is a project wide babel configuration. + * https://babeljs.io/docs/en/config-files#project-wide-configuration + * + * We use this file to apply babel configuration to packages in `node_modules` + * for testing with jest. + */ +module.exports = { + env: { + test: { + presets: [ + ["@babel/env", { targets: "last 2 versions, ie 11", modules: false }], + "@babel/react", + ], + }, + }, +}; diff --git a/config/jest/client.config.js b/config/jest/client.config.js index 975397a29..59055e53b 100644 --- a/config/jest/client.config.js +++ b/config/jest/client.config.js @@ -16,6 +16,7 @@ module.exports = { testEnvironment: "node", testURL: "http://localhost", transform: { + "^.+\\.jsx?$": "/node_modules/babel-jest", "^.+\\.tsx?$": "/node_modules/ts-jest", "^.+\\.css$": "/config/jest/cssTransform.js", "^.+\\.ftl$": "/config/jest/contentTransform.js", @@ -23,7 +24,7 @@ module.exports = { "/config/jest/fileTransform.js", }, transformIgnorePatterns: [ - "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|ts|tsx)$", + "[/\\\\]node_modules[/\\\\](?!(fluent)[/\\\\]).+\\.(js|jsx|mjs|ts|tsx)$", ], moduleNameMapper: { "^talk-admin/(.*)$": "/src/core/client/admin/$1", diff --git a/package-lock.json b/package-lock.json index 6bba3f88a..c579a6649 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3611,9 +3611,9 @@ } }, "babel-jest": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-23.2.0.tgz", - "integrity": "sha1-FKnWo/QSLf6mBp03CFrfJqU6Tbo=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-23.6.0.tgz", + "integrity": "sha512-lqKGG6LYXYu+DQh/slrQ8nxXQkEkhugdXsU6St7GmhVS7Ilc/22ArwqXNJrf0QaOBjZB0360qZMwXqDYQHXaew==", "dev": true, "requires": { "babel-plugin-istanbul": "^4.1.6", diff --git a/package.json b/package.json index a9a89ef06..045d95cc0 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "@types/ws": "^5.1.2", "autoprefixer": "^8.6.5", "babel-core": "^7.0.0-bridge.0", + "babel-jest": "^23.6.0", "babel-loader": "^8.0.0-beta", "babel-plugin-module-resolver": "^3.1.1", "babel-plugin-relay": "^1.7.0-rc.1", diff --git a/src/core/client/framework/lib/i18n/functions/SHORT_NUMBER.ts b/src/core/client/framework/lib/i18n/functions/SHORT_NUMBER.ts new file mode 100644 index 000000000..72deefde2 --- /dev/null +++ b/src/core/client/framework/lib/i18n/functions/SHORT_NUMBER.ts @@ -0,0 +1,10 @@ +import { FluentNumber, FluentType } from "fluent/compat"; + +import { FluentShortNumber } from "../types"; + +export default function SHORT_NUMBER([t]: [FluentType]) { + if (!(t instanceof FluentNumber)) { + throw new Error(`Invalid argument for SHORT_NUMBER ${t.valueOf()}`); + } + return new FluentShortNumber(t.valueOf()); +} diff --git a/src/core/client/framework/lib/i18n/functions/index.ts b/src/core/client/framework/lib/i18n/functions/index.ts new file mode 100644 index 000000000..bb76634a9 --- /dev/null +++ b/src/core/client/framework/lib/i18n/functions/index.ts @@ -0,0 +1 @@ +export { default as SHORT_NUMBER } from "./SHORT_NUMBER"; diff --git a/src/core/client/framework/lib/i18n.ts b/src/core/client/framework/lib/i18n/generateBundles.ts similarity index 55% rename from src/core/client/framework/lib/i18n.ts rename to src/core/client/framework/lib/i18n/generateBundles.ts index de76266d4..bec8f4868 100644 --- a/src/core/client/framework/lib/i18n.ts +++ b/src/core/client/framework/lib/i18n/generateBundles.ts @@ -1,50 +1,8 @@ import "fluent-intl-polyfill/compat"; -import { negotiateLanguages as negotiate } from "fluent-langneg/compat"; import { FluentBundle } from "fluent/compat"; -export interface BundledLocales { - [locale: string]: string; -} - -export interface LoadableLocales { - [locale: string]: (() => Promise); -} - -/** - * This type describes the shape of the generated code from our `locales-loader`. - * Please check `./src/loaders` and the webpack config for more information. - */ -export interface LocalesData { - readonly defaultLocale: string; - readonly fallbackLocale: string; - readonly availableLocales: ReadonlyArray; - readonly bundled: BundledLocales; - readonly loadables: LoadableLocales; -} - -/** - * negotiateLanguages accepts `userLocales` which usually comes from - * `navigator.languages` and the locales `data` as generated by - * the `locales-loader` and returns an array of matching languages. - */ -export function negotiateLanguages( - userLocales: ReadonlyArray, - data: LocalesData -) { - // Choose locale that is best for the user. - const languages = negotiate(userLocales, data.availableLocales, { - defaultLocale: data.defaultLocale, - strategy: "lookup", - }); - - if (data.fallbackLocale && languages[0] !== data.fallbackLocale) { - // Use default locale as fallback in case we have - // missing keys. - languages.push(data.fallbackLocale); - } - - return languages; -} +import * as functions from "./functions"; +import { LocalesData } from "./locales"; // Don't warn in production. let decorateWarnMissing = (bundle: FluentBundle) => bundle; @@ -81,14 +39,14 @@ if (process.env.NODE_ENV !== "production") { * * Use it in conjunction with `negotiateLanguages`. */ -export async function generateBundles( +export default async function generateBundles( locales: ReadonlyArray, data: LocalesData ): Promise { const promises = []; for (const locale of locales) { - const bundle = new FluentBundle(locale); + const bundle = new FluentBundle(locale, { functions }); if (locale in data.bundled) { bundle.addMessages(data.bundled[locale]); promises.push(decorateWarnMissing(bundle)); diff --git a/src/core/client/framework/lib/i18n/index.ts b/src/core/client/framework/lib/i18n/index.ts new file mode 100644 index 000000000..98ef23eec --- /dev/null +++ b/src/core/client/framework/lib/i18n/index.ts @@ -0,0 +1,3 @@ +export { default as generateBundles } from "./generateBundles"; +export { default as negotiateLanguages } from "./negotiateLanguages"; +export { BundledLocales, LoadableLocales, LocalesData } from "./locales"; diff --git a/src/core/client/framework/lib/i18n/locales.ts b/src/core/client/framework/lib/i18n/locales.ts new file mode 100644 index 000000000..621867bed --- /dev/null +++ b/src/core/client/framework/lib/i18n/locales.ts @@ -0,0 +1,19 @@ +export interface BundledLocales { + [locale: string]: string; +} + +export interface LoadableLocales { + [locale: string]: (() => Promise); +} + +/** + * This type describes the shape of the generated code from our `locales-loader`. + * Please check `./src/loaders` and the webpack config for more information. + */ +export interface LocalesData { + readonly defaultLocale: string; + readonly fallbackLocale: string; + readonly availableLocales: ReadonlyArray; + readonly bundled: BundledLocales; + readonly loadables: LoadableLocales; +} diff --git a/src/core/client/framework/lib/i18n/negotiateLanguages.ts b/src/core/client/framework/lib/i18n/negotiateLanguages.ts new file mode 100644 index 000000000..cb5bf06d1 --- /dev/null +++ b/src/core/client/framework/lib/i18n/negotiateLanguages.ts @@ -0,0 +1,27 @@ +import { negotiateLanguages as negotiate } from "fluent-langneg/compat"; + +import { LocalesData } from "./locales"; + +/** + * negotiateLanguages accepts `userLocales` which usually comes from + * `navigator.languages` and the locales `data` as generated by + * the `locales-loader` and returns an array of matching languages. + */ +export default function negotiateLanguages( + userLocales: ReadonlyArray, + data: LocalesData +) { + // Choose locale that is best for the user. + const languages = negotiate(userLocales, data.availableLocales, { + defaultLocale: data.defaultLocale, + strategy: "lookup", + }); + + if (data.fallbackLocale && languages[0] !== data.fallbackLocale) { + // Use default locale as fallback in case we have + // missing keys. + languages.push(data.fallbackLocale); + } + + return languages; +} diff --git a/src/core/client/framework/lib/i18n/types/FluentShortNumber.spec.ts b/src/core/client/framework/lib/i18n/types/FluentShortNumber.spec.ts new file mode 100644 index 000000000..60d27dd9a --- /dev/null +++ b/src/core/client/framework/lib/i18n/types/FluentShortNumber.spec.ts @@ -0,0 +1,33 @@ +import { toPairs } from "lodash"; +import { getShortNumberCode, validateFormat } from "./FluentShortNumber"; + +describe("getShortNumberCode", () => { + it("returns correct value", () => { + const cases = { + 123: "100", + 4322: "1000", + 33223: "10000", + }; + toPairs(cases).forEach(([i, o]) => { + expect(getShortNumberCode(parseFloat(i))).toBe(o); + }); + }); +}); + +describe("validateFormat", () => { + it("returns correct value", () => { + const cases = { + "0k": true, + "0kilo": true, + "0.0": false, + "0": false, + "0.": false, + "0.0k": true, + "000.0k": true, + "000M": true, + }; + toPairs(cases).forEach(([i, o]) => { + expect(validateFormat(i)).toBe(o); + }); + }); +}); diff --git a/src/core/client/framework/lib/i18n/types/FluentShortNumber.ts b/src/core/client/framework/lib/i18n/types/FluentShortNumber.ts new file mode 100644 index 000000000..11f0a5dd8 --- /dev/null +++ b/src/core/client/framework/lib/i18n/types/FluentShortNumber.ts @@ -0,0 +1,81 @@ +import { FluentBundle, FluentNumber, FluentType } from "fluent/compat"; + +const formatRegExp = /^(0+|0+\.0+)[^\d\.]+$/; + +export function validateFormat(fmt: string) { + return formatRegExp.test(fmt); +} + +export function getShortNumberCode(n: number) { + let code = "1"; + while (n >= 10) { + n /= 10; + code += "0"; + } + return code; +} + +function formatShortNumber(n: number, format: string, bundle: FluentBundle) { + const lastIndexOf0 = format.lastIndexOf("0"); + const unit = format.substr(lastIndexOf0 + 1); + const rest = format.substr(0, lastIndexOf0 + 1); + const splitted = rest.split("."); + const digits = splitted[0].length; + const fractalDigits = (splitted.length > 1 && splitted[1].length) || 0; + const threshold = Math.pow(10, digits); + while (n > threshold) { + n /= 10; + } + const formattedNumber = new FluentNumber(n, { + maximumFractionDigits: fractalDigits, + }).toString(bundle); + return `${formattedNumber}${unit}`; +} + +export default class FluentShortNumber extends FluentNumber { + constructor(value: any, opts?: any) { + super(value, opts); + } + + public toString(bundle: FluentBundle) { + if (this.value < 1000) { + return super.toString(bundle); + } + const key = `framework-shortNumber-${getShortNumberCode(this.value)}`; + const fmt = bundle.getMessage(key); + + // Handle message not found. + if (!fmt) { + const message = `Missing translation key for ${key} for languages ${bundle.locales.toString()}`; + if (process.env.NODE_ENV === "production") { + // tslint:disable-next-line:no-console + console.warn(message); + } else { + throw new Error(message); + } + return super.toString(bundle); + } + + // Check for invalid message. + if (!validateFormat(fmt)) { + const message = `Invalid Short Number Format ${fmt}`; + if (process.env.NODE_ENV === "production") { + // tslint:disable-next-line:no-console + console.warn(message); + } else { + throw new Error(message); + } + return super.toString(bundle); + } + + return formatShortNumber(this.value, fmt, bundle); + } + + public match(bundle: FluentBundle, other: FluentType) { + if (other instanceof FluentShortNumber) { + return this.value === other.valueOf; + } + + return false; + } +} diff --git a/src/core/client/framework/lib/i18n/types/index.ts b/src/core/client/framework/lib/i18n/types/index.ts new file mode 100644 index 000000000..346ccda3f --- /dev/null +++ b/src/core/client/framework/lib/i18n/types/index.ts @@ -0,0 +1 @@ +export { default as FluentShortNumber } from "./FluentShortNumber"; diff --git a/src/core/client/framework/testHelpers/createFluentBundle.ts b/src/core/client/framework/testHelpers/createFluentBundle.ts index d524576e4..436a20fc7 100644 --- a/src/core/client/framework/testHelpers/createFluentBundle.ts +++ b/src/core/client/framework/testHelpers/createFluentBundle.ts @@ -1,4 +1,8 @@ +import "fluent-intl-polyfill/compat"; import { FluentBundle } from "fluent/compat"; + +import * as functions from "talk-framework/lib/i18n/functions"; + import fs from "fs"; import path from "path"; @@ -34,7 +38,7 @@ function createFluentBundle( target: string, pathToLocale: string ): FluentBundle { - const bundle = new FluentBundle("en-US"); + const bundle = new FluentBundle("en-US", { functions }); const files = fs.readdirSync(pathToLocale); const prefixes = commonPrefixes.concat(target); files.forEach(f => { diff --git a/src/locales/en-US/framework.ftl b/src/locales/en-US/framework.ftl index d67b7ad55..ce96259c6 100644 --- a/src/locales/en-US/framework.ftl +++ b/src/locales/en-US/framework.ftl @@ -2,6 +2,19 @@ ### All keys must start with `framework` because this file is shared ### among different targets. +## Short Number + +# Implementation based on unicode Short Number patterns +# http://cldr.unicode.org/translation/number-patterns#TOC-Short-Numbers + +framework-shortNumber-1000 = 0.0k +framework-shortNumber-10000 = 00k +framework-shortNumber-100000 = 000k +framework-shortNumber-1000000 = 0.0M +framework-shortNumber-10000000 = 00M +framework-shortNumber-100000000 = 000M +framework-shortNumber-1000000000 = 0.0B + ## Validation framework-validation-required = This field is required. diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 04d414abb..ceb0ce01c 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -12,7 +12,7 @@ general-userBoxAuthenticated-signedInAs = general-userBoxAuthenticated-notYou = Not you? -general-app-commentsTab = {$commentCount} { $commentCount -> +general-app-commentsTab = { SHORT_NUMBER($commentCount) } { $commentCount -> [0] Comments [1] Comment *[other] Comments diff --git a/src/types/fluent.d.ts b/src/types/fluent.d.ts index b64509258..b53ae7ecc 100644 --- a/src/types/fluent.d.ts +++ b/src/types/fluent.d.ts @@ -31,9 +31,9 @@ declare module "fluent-langneg/compat" { declare module "fluent/compat" { export interface FluentBundleOptions { - functions: { [key: string]: (...args: any[]) => string }; - useIsolating: boolean; - transform: ((s: string) => string); + functions?: { [key: string]: (...args: any[]) => string | FluentType }; + useIsolating?: boolean; + transform?: ((s: string) => string); } export class FluentBundle { @@ -49,4 +49,15 @@ declare module "fluent/compat" { errors?: string[] ): string | null; } + + export class FluentType { + protected value: any; + protected opts: any; + constructor(value: any, opts?: any); + valueOf(): any; + toString(bundle: FluentBundle): string; + } + + export class FluentNumber extends FluentType {} + export class FluentDateTime extends FluentType {} }