mirror of
https://github.com/wassname/talk.git
synced 2026-08-03 13:20:59 +08:00
[next] i18n short number support (#1992)
* feat: Implement i18n short number * test: fix failing tests
This commit is contained in:
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
-3
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
+4
-46
@@ -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 => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+14
-3
@@ -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 {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user