-
-
Notifications
You must be signed in to change notification settings - Fork 251
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
base: main
Are you sure you want to change the base?
Changes from all commits
ee8279c
7997a50
0f25794
e89de9c
2dc39b5
3fa4253
44337ea
69bfe0c
1d44910
7afec20
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import type {CompiledAst} from 'icu-to-json'; | ||
|
||
type MessageFormat = CompiledAst; | ||
|
||
export default MessageFormat; |
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; |
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,23 +1,18 @@ | ||
// eslint-disable-next-line import/no-named-as-default -- False positive | ||
import IntlMessageFormat from 'intl-messageformat'; | ||
import { | ||
cloneElement, | ||
isValidElement, | ||
ReactElement, | ||
ReactNode, | ||
ReactNodeArray | ||
} from 'react'; | ||
import {evaluateAst} from 'icu-to-json'; | ||
import {compileToJson} from 'icu-to-json/compiler'; | ||
import React, {Fragment, ReactElement} from 'react'; | ||
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'; | ||
|
@@ -56,34 +51,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, | ||
|
@@ -132,23 +99,6 @@ export type CreateBaseTranslatorProps<Messages> = InitializedIntlConfig & { | |
messagesOrError: Messages | IntlError; | ||
}; | ||
|
||
function getPlainMessage(candidate: string, values?: unknown) { | ||
if (values) return undefined; | ||
|
||
const unescapedMessage = candidate.replace(/'([{}])/gi, '$1'); | ||
|
||
// Placeholders can be in the message if there are default values, | ||
// or if the user has forgotten to provide values. In the latter | ||
// case we need to compile the message to receive an error. | ||
const hasPlaceholders = /<|{/.test(unescapedMessage); | ||
|
||
if (!hasPlaceholders) { | ||
return unescapedMessage; | ||
} | ||
|
||
return undefined; | ||
} | ||
|
||
export default function createBaseTranslator< | ||
Messages extends AbstractIntlMessages, | ||
NestedKey extends NestedKeyOf<Messages> | ||
|
@@ -196,7 +146,7 @@ function createBaseTranslatorImpl< | |
values?: RichTranslationValues, | ||
/** Provide custom formats for numbers, dates and times. */ | ||
formats?: Partial<Formats> | ||
): string | ReactElement | ReactNodeArray { | ||
): string | ReactElement { | ||
if (messagesOrError instanceof IntlError) { | ||
// We have already warned about this during render | ||
return getMessageFallback({ | ||
|
@@ -224,7 +174,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 { | ||
|
@@ -251,19 +201,8 @@ function createBaseTranslatorImpl< | |
return getFallbackFromErrorAndNotify(key, code, errorMessage); | ||
} | ||
|
||
// Hot path that avoids creating an `IntlMessageFormat` instance | ||
const plainMessage = getPlainMessage(message as string, values); | ||
if (plainMessage) return plainMessage; | ||
|
||
try { | ||
messageFormat = new IntlMessageFormat( | ||
message, | ||
locale, | ||
convertFormatsToIntlMessageFormat( | ||
{...globalFormats, ...formats}, | ||
timeZone | ||
) | ||
); | ||
messageFormat = compileToJson(message); | ||
} catch (error) { | ||
return getFallbackFromErrorAndNotify( | ||
key, | ||
|
@@ -276,14 +215,32 @@ 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 allValues = {...defaultTranslationValues, ...values}; | ||
// TODO: The return type seems to be a bit off, not sure if | ||
// this should be handled in `icu-to-json` or here. | ||
const evaluated = evaluateAst( | ||
messageFormat, | ||
locale, | ||
allValues, | ||
getFormatters(timeZone, formats, globalFormats) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should I pass the values from the user or There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the args are not needed for formatters however the args can tell you during compile time which formatters need to be available to run the code. in your case there is a very special formatter wich you should add for full react support it allows processing the children before they are passed to the given tag function: {
tag: (children: Array<string | ReactNode>, locale: string) =>
children.map((value, i) => typeof value === "string"
? value
: <Fragment key={`f-${i}`}>{value}</Fragment>
} e.g. for a translation like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. there is also the by default the tag is converted to a string but you might also wrap in a fragment or even allow some tags to be used as html: // for a compiled message "<b>Hello {name}</b>"
const formatters = {
baseTag: (Tag, chilren) => {
// allowlist:
if (["b", "strong", "p"].include(Tag)) {
return <Tag>{children}</Tag>
}
return <>{children}</>;
}
};
run(message, lang, { name: "Joe" }, formatters) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried updating your example here to use nested rich text: https://codesandbox.io/p/sandbox/icu-to-json-demo-forked-2jzngr?file=%2Fsrc%2Ficu.tsx%3A19%2C1-20%2C1 Somehow the inner part is missing. Am I doing something wrong? |
||
); | ||
|
||
let formattedMessage; | ||
if (evaluated.length === 0) { | ||
// Empty | ||
formattedMessage = ''; | ||
} else if (evaluated.length === 1 && typeof evaluated[0] === 'string') { | ||
// Plain text | ||
formattedMessage = evaluated[0]; | ||
} else { | ||
// Rich text | ||
formattedMessage = evaluated.map((part, index) => ( | ||
// @ts-expect-error TODO | ||
<Fragment key={index}>{part}</Fragment> | ||
)); | ||
} | ||
|
||
// TODO: Add a test that verifies when we need this | ||
if (formattedMessage == null) { | ||
throw new Error( | ||
process.env.NODE_ENV !== 'production' | ||
|
@@ -294,13 +251,8 @@ function createBaseTranslatorImpl< | |
); | ||
} | ||
|
||
// Limit the function signature to return strings or React elements | ||
return isValidElement(formattedMessage) || | ||
// Arrays of React elements | ||
Array.isArray(formattedMessage) || | ||
typeof formattedMessage === 'string' | ||
? formattedMessage | ||
: String(formattedMessage); | ||
// @ts-expect-error Verify return type (see comment above) | ||
return formattedMessage; | ||
} catch (error) { | ||
return getFallbackFromErrorAndNotify( | ||
key, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
there are two different ways to run the precompiled icu messages:
run
evaluateAst
the idea is that
run
will always return a string andevaluateAst
will always return an array.icu-to-json
is framework independent so it would require you to wrap a react Fragment around the result:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, that helps with the fragment—thanks!
I was wondering about this case:
It seems like the
args
determine the return value. However, the functionb
has already been called at this point (having returned aReact.JSX.Element
) anddate
should be turned into a string.Therefore the returned value should have the type
(string | React.JSX.Element)[]
, right?Or am I missing something?