[next] i18n short number support (#1992)

* feat: Implement i18n short number

* test: fix failing tests
This commit is contained in:
Kiwi
2018-10-12 23:16:13 +00:00
committed by Wyatt Johnson
parent 3636e47b16
commit c2ffb30431
17 changed files with 235 additions and 55 deletions
+17
View File
@@ -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",
],
},
},
};
+2 -1
View File
@@ -16,6 +16,7 @@ module.exports = {
testEnvironment: "node",
testURL: "http://localhost",
transform: {
"^.+\\.jsx?$": "<rootDir>/node_modules/babel-jest",
"^.+\\.tsx?$": "<rootDir>/node_modules/ts-jest",
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"^.+\\.ftl$": "<rootDir>/config/jest/contentTransform.js",
@@ -23,7 +24,7 @@ module.exports = {
"<rootDir>/config/jest/fileTransform.js",
},
transformIgnorePatterns: [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|ts|tsx)$",
"[/\\\\]node_modules[/\\\\](?!(fluent)[/\\\\]).+\\.(js|jsx|mjs|ts|tsx)$",
],
moduleNameMapper: {
"^talk-admin/(.*)$": "<rootDir>/src/core/client/admin/$1",
+3 -3
View File
@@ -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",
+1
View File
@@ -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",
@@ -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());
}
@@ -0,0 +1 @@
export { default as SHORT_NUMBER } from "./SHORT_NUMBER";
@@ -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<string>);
}
/**
* 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<string>;
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<string>,
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<string>,
data: LocalesData
): Promise<FluentBundle[]> {
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));
@@ -0,0 +1,3 @@
export { default as generateBundles } from "./generateBundles";
export { default as negotiateLanguages } from "./negotiateLanguages";
export { BundledLocales, LoadableLocales, LocalesData } from "./locales";
@@ -0,0 +1,19 @@
export interface BundledLocales {
[locale: string]: string;
}
export interface LoadableLocales {
[locale: string]: (() => Promise<string>);
}
/**
* 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<string>;
readonly bundled: BundledLocales;
readonly loadables: LoadableLocales;
}
@@ -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<string>,
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;
}
@@ -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);
});
});
});
@@ -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;
}
}
@@ -0,0 +1 @@
export { default as FluentShortNumber } from "./FluentShortNumber";
@@ -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 => {
+13
View File
@@ -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.
+1 -1
View File
@@ -12,7 +12,7 @@ general-userBoxAuthenticated-signedInAs =
general-userBoxAuthenticated-notYou =
Not you? <button>Sign Out</button>
general-app-commentsTab = {$commentCount} { $commentCount ->
general-app-commentsTab = { SHORT_NUMBER($commentCount) } { $commentCount ->
[0] Comments
[1] Comment
*[other] Comments
+14 -3
View File
@@ -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 {}
}