Skip to content

Commit

Permalink
refactor(core) bump messageformat parser. Add TS types
Browse files Browse the repository at this point in the history
Resolves #1278
  • Loading branch information
thekip committed Jan 9, 2023
1 parent 2f500a9 commit 88ac94a
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 122 deletions.
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@
"dependencies": {
"@babel/runtime": "^7.11.2",
"make-plural": "^6.2.2",
"messageformat-parser": "^4.1.3"
"@messageformat/parser": "^5.0.0"
}
}
60 changes: 35 additions & 25 deletions packages/core/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { CompiledMessage, Locales } from "./i18n"
import {CompiledMessage, Formats, LocaleData, Locales, Values} from "./i18n"
import { date, number } from "./formats"
import { isString, isFunction } from "./essentials"

export const UNICODE_REGEX = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g;

const defaultFormats = (
locale,
locales,
localeData = { plurals: undefined },
formats = {}
locale: string,
locales: Locales,
localeData: LocaleData = { plurals: undefined },
formats: Formats = {}
) => {
locales = locales || locale
const { plurals } = localeData
const style = (format) =>
isString(format) ? formats[format] || { style: format } : format
const replaceOctothorpe = (value, message) => {
const style = <T>(format: string | T): T =>
isString(format)
? formats[format] || { style: format }
: format as any
const replaceOctothorpe = (value: number, message) => {
return (ctx) => {
const msg = isFunction(message) ? message(ctx) : message
const norm = Array.isArray(msg) ? msg : [msg]
Expand All @@ -29,26 +31,28 @@ const defaultFormats = (
}

return {
plural: (value, { offset = 0, ...rules }) => {
const message = rules[value] || rules[plurals?.(value - offset)] || rules.other
plural: (value: number, { offset = 0, ...rules }) => {
const message = rules[value] || rules[plurals?.(value - offset)] || rules.other

return replaceOctothorpe(value - offset, message)
},

selectordinal: (value, { offset = 0, ...rules }) => {
const message = rules[value] || rules[plurals?.(value - offset, true)] || rules.other
selectordinal: (value: number, { offset = 0, ...rules }) => {
const message = rules[value] || rules[plurals?.(value - offset, true)] || rules.other
return replaceOctothorpe(value - offset, message)
},

select: (value, rules) => rules[value] || rules.other,
select: (value: string, rules) => rules[value] || rules.other,

number: (value, format) => number(locales, style(format))(value),
number: (value: number, format: string | Intl.NumberFormatOptions) => number(locales, style(format))(value),

date: (value, format) => date(locales, style(format))(value),
date: (value: string, format: string | Intl.DateTimeFormatOptions) => date(locales, style(format))(value),

undefined: (value) => value,
undefined: (value: unknown) => value,
}
}


// Params -> CTX
/**
* Creates a context object, which formats ICU MessageFormat arguments based on
Expand All @@ -61,7 +65,13 @@ const defaultFormats = (
* @param formats - Custom format styles
* @returns {function(string, string, any)}
*/
function context({ locale, locales, values, formats, localeData }) {
function context(
locale: string,
locales: Locales,
values: Values,
formats: Formats,
localeData: LocaleData
) {
const formatters = defaultFormats(locale, locales, localeData, formats)

const ctx = (name: string, type: string, format: any): string => {
Expand All @@ -78,21 +88,21 @@ export function interpolate(
translation: CompiledMessage,
locale: string,
locales: Locales,
localeData: Object
localeData: LocaleData
) {
return (values: Object, formats: Object = {}): string => {
const ctx = context({
return (values: Values, formats: Formats = {}): string => {
const ctx = context(
locale,
locales,
localeData,
formats,
values,
})
formats,
localeData,
)

const formatMessage = (message) => {
const formatMessage = (message: CompiledMessage): string => {
if (!Array.isArray(message)) return message

return message.reduce((message, token) => {
return message.reduce<string>((message, token) => {
if (isString(token)) return message + token

const [name, type, format] = token
Expand Down
125 changes: 67 additions & 58 deletions packages/core/src/dev/compile.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import compile from "./compile"
import { mockEnv, mockConsole } from "@lingui/jest-mocks"
import { interpolate } from "../context"
import {Locale, Locales} from "../i18n"

describe("compile", function () {
describe("compile", () => {
const englishPlurals = {
plurals(value, ordinal) {
plurals(value: number, ordinal: boolean) {
if (ordinal) {
return { "1": "one", "2": "two", "3": "few" }[value] || "other"
} else {
Expand All @@ -13,19 +14,19 @@ describe("compile", function () {
},
}

const prepare = (translation, locale?, locales?) =>
interpolate(compile(translation), locale || "en", locales, englishPlurals)
const prepare = (translation: string, locale?: Locale, locales?: Locales) => {
const tokens = compile(translation);
return interpolate(tokens, locale || "en", locales, englishPlurals)
}

it("should handle an error if message has syntax errors", function () {
it("should handle an error if message has syntax errors", () => {
mockConsole((console) => {
expect(compile("Invalid {{message}}")).toEqual("Invalid {{message}}")
expect(console.error).toBeCalledWith(
"Message cannot be parsed due to syntax errors: Invalid {{message}}"
)
expect(compile("Invalid {message")).toEqual("Invalid {message")
expect(console.error).toBeCalledWith(expect.stringMatching('Unexpected message end at line'))
})
})

it("should compile static message", function () {
it("should compile static message", () => {
const cache = compile("Static message")
expect(cache).toEqual("Static message")

Expand All @@ -35,14 +36,22 @@ describe("compile", function () {
})
})

it("should compile message with variable", function () {
it("should compile message with variable", () => {
const cache = compile("Hey {name}!")
expect(interpolate(cache, "en", [], {})({ name: "Joe" })).toEqual(
"Hey Joe!"
)
})

it("should compile plurals", function () {
it("should not interpolate escaped placeholder", () => {
const msg = prepare("Hey '{name}'!")

expect(msg({})).toEqual(
"Hey {name}!"
)
})

it("should compile plurals", () => {
const plural = prepare(
"{value, plural, one {{value} Book} other {# Books}}"
)
Expand All @@ -57,65 +66,65 @@ describe("compile", function () {
expect(offset({ value: 3 })).toEqual("2 Books")
})

it("should compile selectordinal", function () {
it("should compile selectordinal", () => {
const cache = prepare(
"{value, selectordinal, one {#st Book} two {#nd Book}}"
)
expect(cache({ value: 1 })).toEqual("1st Book")
expect(cache({ value: 2 })).toEqual("2nd Book")
})

it("should compile select", function () {
it("should compile select", () => {
const cache = prepare("{value, select, female {She} other {They}}")
expect(cache({ value: "female" })).toEqual("She")
expect(cache({ value: "n/a" })).toEqual("They")
})

const testVector = [
["en", null, "0.1", "10%", "20%", "3/4/2017", "€0.10", "€1.00"],
["fr", null, "0,1", "10 %", "20 %", "04/03/2017", "0,10 €", "1,00 €"],
["fr", "fr-CH", "0,1", "10%", "20%", "04.03.2017", "0.10 €", "1.00 €"],
]
testVector.forEach((tc) => {
const [
locale,
locales,
expectedNumber,
expectedPercent1,
expectedPercent2,
expectedDate,
expectedCurrency1,
expectedCurrency2,
] = tc

it(
"should compile custom format for locale=" +
locale +
" and locales=" +
describe("Custom format", () => {
const testVector = [
["en", null, "0.1", "10%", "20%", "3/4/2017", "€0.10", "€1.00"],
["fr", null, "0,1", "10 %", "20 %", "04/03/2017", "0,10 €", "1,00 €"],
["fr", "fr-CH", "0,1", "10%", "20%", "04.03.2017", "0.10 €", "1.00 €"]
]
testVector.forEach((tc) => {
const [
locale,
locales,
function () {
const number = prepare("{value, number}", locale, locales)
expect(number({ value: 0.1 })).toEqual(expectedNumber)

const percent = prepare("{value, number, percent}", locale, locales)
expect(percent({ value: 0.1 })).toEqual(expectedPercent1)
expect(percent({ value: 0.2 })).toEqual(expectedPercent2)

const now = new Date("3/4/2017")
const date = prepare("{value, date}", locale, locales)
expect(date({ value: now })).toEqual(expectedDate)

const formats = {
currency: {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
},
expectedNumber,
expectedPercent1,
expectedPercent2,
expectedDate,
expectedCurrency1,
expectedCurrency2,
] = tc

it(
`should compile custom format for locale=${locale} and locales=${locales}`,
() => {
const number = prepare("{value, number}", locale, locales)
expect(number({ value: 0.1 })).toEqual(expectedNumber)

const percent = prepare("{value, number, percent}", locale, locales)
expect(percent({ value: 0.1 })).toEqual(expectedPercent1)
expect(percent({ value: 0.2 })).toEqual(expectedPercent2)

const now = new Date("3/4/2017")
const date = prepare("{value, date}", locale, locales)
expect(date({ value: now })).toEqual(expectedDate)

const formats = {
currency: {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2
} as Intl.NumberFormatOptions
}
const currency = prepare("{value, number, currency}", locale, locales)
expect(currency({value: 0.1}, formats)).toEqual(expectedCurrency1)
expect(currency({value: 1}, formats)).toEqual(expectedCurrency2)
}
const currency = prepare("{value, number, currency}", locale, locales)
expect(currency({ value: 0.1 }, formats)).toEqual(expectedCurrency1)
expect(currency({ value: 1 }, formats)).toEqual(expectedCurrency2)
}
)
)

})
})
})
38 changes: 21 additions & 17 deletions packages/core/src/dev/compile.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { parse } from "messageformat-parser"
import { isString } from "../essentials"
import { CompiledMessage } from "../i18n"
import {Content, parse, Token} from "@messageformat/parser"
import {CompiledMessage, CompiledMessageToken} from "../i18n"


// [Tokens] -> (CTX -> String)
function processTokens(tokens) {
if (!tokens.filter((token) => !isString(token)).length) {
return tokens.join("")
function processTokens(tokens: Array<Token>): CompiledMessage {
if (!tokens.filter((token) => token.type !== "content").length) {
return tokens.map(token => (token as Content).value).join("")
}

return tokens.map((token) => {
if (isString(token)) {
return token
return tokens.map<CompiledMessageToken>((token) => {
if (token.type === 'content') {
return token.value

// # in plural case
} else if (token.type === "octothorpe") {
Expand All @@ -22,17 +22,21 @@ function processTokens(tokens) {

// argument with custom format (date, number)
} else if (token.type === "function") {
const _param = token.param && token.param.tokens[0]
const param = typeof _param === "string" ? _param.trim() : _param
return [token.arg, token.key, param].filter(Boolean)
const _param = token?.param?.[0] as Content

if (_param) {
return [token.arg, token.key, _param.value.trim()]
} else {
return [token.arg, token.key]
}
}

const offset = token.offset ? parseInt(token.offset) : undefined
const offset = token.pluralOffset

// complex argument with cases
const formatProps = {}
token.cases.forEach((item) => {
formatProps[item.key] = processTokens(item.tokens)
formatProps[item.key.replace(/^=(.)+/, "$1")] = processTokens(item.tokens)
})

return [
Expand All @@ -41,8 +45,8 @@ function processTokens(tokens) {
{
offset,
...formatProps,
},
]
} as any,
] as CompiledMessageToken
})
}

Expand All @@ -53,7 +57,7 @@ export default function compile(
try {
return processTokens(parse(message))
} catch (e) {
console.error(`Message cannot be parsed due to syntax errors: ${message}`)
console.error(`${e.message} \n\nMessage: ${message}`)
return message
}
}
2 changes: 1 addition & 1 deletion packages/core/src/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,4 @@ function cacheKey<T>(
) {
const localeKey = Array.isArray(locales) ? locales.sort().join('-') : locales
return `${localeKey}-${JSON.stringify(options)}`
}
}
Loading

0 comments on commit 88ac94a

Please sign in to comment.