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 7 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
2 changes: 1 addition & 1 deletion packages/use-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
],
"dependencies": {
"@formatjs/ecma402-abstract": "^1.11.4",
"intl-messageformat": "^9.3.18"
"icu-to-json": "0.0.20"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
Expand Down
9 changes: 9 additions & 0 deletions packages/use-intl/src/core/MessageFormat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type {compile} from 'icu-to-json/compiler';

type MessageFormat = Omit<
ReturnType<typeof compile>,
// TODO: Do we need the args?
'args'
Copy link
Owner Author

@amannn amannn Dec 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be removed from the parsed result? See also https://github.com/amannn/next-intl/pull/705/files#r1418720864

Copy link

@jantimon jantimon Dec 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use compileToJson instead it's the same as compile just without args

args are helpful to generate types or to optimize which formatters should be included to a bundle.
e.g. if date is used you might want to add a date formatter

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thanks!

>;

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;
70 changes: 25 additions & 45 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 {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 @@ -56,34 +59,6 @@ function resolvePath(
return message;
}

function prepareTranslationValues(values: RichTranslationValues) {
if (Object.keys(values).length === 0) return undefined;

// Workaround for https://github.com/formatjs/formatjs/issues/1467
const transformedValues: RichTranslationValues = {};
Object.keys(values).forEach((key) => {
let index = 0;
const value = values[key];

let transformed;
if (typeof value === 'function') {
transformed = (chunks: ReactNode) => {
const result = value(chunks);

return isValidElement(result)
? cloneElement(result, {key: key + index++})
: result;
};
} else {
transformed = value;
}

transformedValues[key] = transformed;
});

return transformedValues;
}

function getMessagesOrError<Messages extends AbstractIntlMessages>({
messages,
namespace,
Expand Down Expand Up @@ -224,7 +199,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 +227,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 +245,25 @@ 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)
);

let formattedMessage;
if (evaluated.length === 0) {
// Empty
formattedMessage = '';
} else if (evaluated.length === 1) {
// Plain text
formattedMessage = evaluated[0];
} else {
// Rich text
formattedMessage = evaluated;
}

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

// 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;

type FormatNameOrArgs<Options> =
| string
| {
type: number; // TODO: Unused, is this necessary?
tokens: Array<unknown>; // TODO: Unused, is this necessary?
Copy link
Owner Author

@amannn amannn Dec 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could these two properties be removed from the parsed result? Or should they be used by the consumer?

Copy link

@jantimon jantimon Dec 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean - Formatters are called here with (value, locale, formatOptions)

https://github.com/jantimon/icu-to-json/blob/83f24322bfd4ab66402e70d9727a650720501925/src/runtime.ts#L150-L152

the format options are passed through from MessageFormat

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean e.g. these cases:

// [["price",4,"numberFmt",{"type":0,"tokens":[{"stem":"currency","options":["EUR"]}],"parsedOptions":{"style":"currency","currency":"EUR"}}]]
'{price, number, ::currency/EUR}'

// [["value",4,"numberFmt",{"type":0,"tokens":[{"stem":".#","options":[]}],"parsedOptions":{"maximumFractionDigits":1}}]]
'{value, number, ::.#}'

Is tokens necessary here? In regard to minimizing the size of the AST, could this be simplified to something like this?

[["value",4,"numberFmt",{"maximumFractionDigits":1}]]

Similarly, date skeletons seem to be a bit verbose:

// [["now",4,"date",{"type":1,"pattern":"yyyyMdHm","parsedOptions":{"year":"numeric","month":"numeric","day":"numeric","hourCycle":"h23","hour":"numeric","minute":"numeric"}}]]
'{now, date, ::yyyyMdHm}'

Could the parsedOptions be unwrapped and replace the object they're placed within?

parsedOptions?: Options;
};

export default function getFormatters(
timeZone?: string,
formats?: Partial<Formats>,
globalFormats?: Partial<Formats>
) {
const formatters = {
date(
value: number | string,
locale: string,
formatNameOrArgs?: FormatNameOrArgs<Intl.DateTimeFormatOptions>
) {
const allFormats = {
...defaults.date,
// TODO: time & date vs dateTime. Maybe we should separate
// time and date, because ICU does this too?
...globalFormats?.dateTime
};

const options: Intl.DateTimeFormatOptions = {timeZone};
if (formatNameOrArgs) {
if (typeof formatNameOrArgs === 'string') {
if (formatNameOrArgs in allFormats) {
Object.assign(options, (allFormats as any)[formatNameOrArgs]);
}
}
if (typeof formatNameOrArgs === 'object') {
Object.assign(options, formatNameOrArgs.parsedOptions);
}
}

// TODO: Use Intl.DateTimeFormat and caching?
return new Date(value).toLocaleDateString(locale, options);
},

time(
value: number | string,
locale: string,
formatNameOrArgs?: FormatNameOrArgs<Intl.DateTimeFormatOptions>
) {
const allFormats = {
...defaults.time,
...globalFormats?.dateTime
};

const options: Intl.DateTimeFormatOptions = {timeZone};
if (formatNameOrArgs) {
if (typeof formatNameOrArgs === 'string') {
if (formatNameOrArgs in allFormats) {
Object.assign(options, (allFormats as any)[formatNameOrArgs]);
}
}
if (typeof formatNameOrArgs === 'object') {
Object.assign(options, formatNameOrArgs.parsedOptions);
}
}

// TODO: Use Intl.DateTimeFormat and caching?
return new Date(value).toLocaleTimeString(locale, options);
},

numberFmt(
value: number,
locale: string,
formatNameOrArgs?: FormatNameOrArgs<Intl.NumberFormatOptions>
) {
const allFormats = {
...defaults.number,
...globalFormats?.number,
...formats?.number
};

const options: Intl.NumberFormatOptions = {};
if (formatNameOrArgs) {
if (typeof formatNameOrArgs === 'string') {
// Based on https://github.com/messageformat/messageformat/blob/main/packages/runtime/src/fmt/number.ts
const [formatName, currency] = formatNameOrArgs.split(':') || [];

if (formatNameOrArgs in allFormats) {
Object.assign(options, (allFormats as any)[formatName]);
}
if (currency) {
options.currency = currency;
}
}
if (typeof formatNameOrArgs === 'object') {
Object.assign(options, formatNameOrArgs.parsedOptions);
}
}

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

return formatters;
}
Loading
Loading