[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
@@ -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 => {