Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: AOT compilation with icu-to-json (experiment) #705

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/use-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@
],
"dependencies": {
"@formatjs/ecma402-abstract": "^1.11.4",
"intl-messageformat": "^9.3.18"
"@messageformat/runtime": "^3.0.1",
"icu-to-json": "0.0.20"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
Expand Down
5 changes: 5 additions & 0 deletions packages/use-intl/src/core/MessageFormat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type {compile} from 'icu-to-json/compiler';

type MessageFormat = ReturnType<typeof compile>;

export default MessageFormat;
7 changes: 3 additions & 4 deletions packages/use-intl/src/core/MessageFormatCache.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
// eslint-disable-next-line import/no-named-as-default -- False positive
import type IntlMessageFormat from 'intl-messageformat';
import MessageFormat from './MessageFormat';

type MessageFormatCache = Map<
/** Format: `${locale}.${namespace}.${key}.${message}` */
string,
IntlMessageFormat
string, // Could simplify the key here
MessageFormat
>;

export default MessageFormatCache;
33 changes: 16 additions & 17 deletions packages/use-intl/src/core/createBaseTranslator.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// eslint-disable-next-line import/no-named-as-default -- False positive
import IntlMessageFormat from 'intl-messageformat';
// import IntlMessageFormat from 'intl-messageformat';
import {run, evaluateAst} from 'icu-to-json';
import {compile} from 'icu-to-json/compiler';
import {
cloneElement,
isValidElement,
Expand All @@ -11,13 +13,14 @@ import AbstractIntlMessages from './AbstractIntlMessages';
import Formats from './Formats';
import {InitializedIntlConfig} from './IntlConfig';
import IntlError, {IntlErrorCode} from './IntlError';
import MessageFormat from './MessageFormat';
import MessageFormatCache from './MessageFormatCache';
import TranslationValues, {
MarkupTranslationValues,
RichTranslationValues
} from './TranslationValues';
import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat';
import {defaultGetMessageFallback, defaultOnError} from './defaults';
import getFormatters from './getFormatters';
import MessageKeys from './utils/MessageKeys';
import NestedKeyOf from './utils/NestedKeyOf';
import NestedValueOf from './utils/NestedValueOf';
Expand Down Expand Up @@ -224,7 +227,7 @@ function createBaseTranslatorImpl<

const cacheKey = joinPath([locale, namespace, key, String(message)]);

let messageFormat: IntlMessageFormat;
let messageFormat: MessageFormat;
if (messageFormatCache?.has(cacheKey)) {
messageFormat = messageFormatCache.get(cacheKey)!;
} else {
Expand Down Expand Up @@ -252,18 +255,12 @@ function createBaseTranslatorImpl<
}

// Hot path that avoids creating an `IntlMessageFormat` instance
// TODO: We can get rid of this with icu-to-json
const plainMessage = getPlainMessage(message as string, values);
if (plainMessage) return plainMessage;

try {
messageFormat = new IntlMessageFormat(
message,
locale,
convertFormatsToIntlMessageFormat(
{...globalFormats, ...formats},
timeZone
)
);
messageFormat = compile(message);
} catch (error) {
return getFallbackFromErrorAndNotify(
key,
Expand All @@ -276,14 +273,16 @@ function createBaseTranslatorImpl<
}

try {
const formattedMessage = messageFormat.format(
// @ts-expect-error `intl-messageformat` expects a different format
// for rich text elements since a recent minor update. This
// needs to be evaluated in detail, possibly also in regards
// to be able to format to parts.
prepareTranslationValues({...defaultTranslationValues, ...values})
const evaluated = evaluateAst(
messageFormat.json,
locale,
{...defaultTranslationValues, ...values},
getFormatters(timeZone, formats, globalFormats)
);

const isRichText = evaluated.length > 1;
const formattedMessage = isRichText ? evaluated : evaluated.join('');

if (formattedMessage == null) {
throw new Error(
process.env.NODE_ENV !== 'production'
Expand Down
122 changes: 122 additions & 0 deletions packages/use-intl/src/core/getFormatters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import Formats from './Formats';

// TODO: Move to a scoped cache to avoid memory leaks?
const numberFormats: Record<string, Intl.NumberFormat> = {};

// TODO: time & date vs dateTime. Maybe we should just use dateTime?

// Copied from intl-messageformat
const defaults = {
number: {
integer: {maximumFractionDigits: 0},
currency: {style: 'currency'},
percent: {style: 'percent'}
},
date: {
short: {month: 'numeric', day: 'numeric', year: '2-digit'},
medium: {month: 'short', day: 'numeric', year: 'numeric'},
long: {month: 'long', day: 'numeric', year: 'numeric'},
full: {weekday: 'long', month: 'long', day: 'numeric', year: 'numeric'}
},
time: {
short: {hour: 'numeric', minute: 'numeric'},
medium: {hour: 'numeric', minute: 'numeric', second: 'numeric'},
long: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short'
},
full: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short'
}
}
} as const;

function formatNumber(
locale: string | Array<string>,
opt: Intl.NumberFormatOptions
) {
const key = String(locale) + JSON.stringify(opt);
if (!numberFormats[key]) {
numberFormats[key] = new Intl.NumberFormat(locale, opt);
}
return numberFormats[key];
}

export default function getFormatters(
timeZone?: string,
formats?: Partial<Formats>,
globalFormats?: Partial<Formats>
) {
const formatters = {
date(
value: number | string,
locale: string,
formatName?: keyof typeof defaults.date
) {
const allFormats = {
...defaults.date,
...globalFormats?.dateTime
};

const options: Intl.DateTimeFormatOptions = {timeZone};

if (formatName && formatName in allFormats) {
Object.assign(options, allFormats[formatName]);
}

// TODO: Use Intl.DateTimeFormat and caching?
return new Date(value).toLocaleDateString(locale, options);
},
time(
value: number | string,
locale: string,
formatName?: keyof typeof defaults.time
) {
const allFormats = {
...defaults.time,
...globalFormats?.dateTime
};

const options: Intl.DateTimeFormatOptions = {timeZone};

if (formatName && formatName in allFormats) {
Object.assign(options, allFormats[formatName]);
}

// TODO: Use Intl.DateTimeFormat and caching?
return new Date(value).toLocaleTimeString(locale, options);
},
numberFmt(
value: number,
locale: string,
arg: string,
defaultCurrency: string
) {
const allFormats = {
...defaults.number,
...globalFormats?.number,
...formats?.number
};

// Based on https://github.com/messageformat/messageformat/blob/main/packages/runtime/src/fmt/number.ts
const [formatName, currency] = (arg && arg.split(':')) || [];

const options: Intl.NumberFormatOptions = {currency};

if (formatName && formatName in allFormats) {
Object.assign(options, allFormats[formatName]);
}

// TODO: Caching?
const format = new Intl.NumberFormat(locale, options);
return format.format(value);
}
};

return formatters;
}
Loading