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

refactor(macro): make code strongly typed #1321

Merged
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion packages/macro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
"peerDependencies": {
"@lingui/core": "^3.13.0",
"@lingui/react": "^3.13.0",
"babel-plugin-macros": "2 || 3"
"babel-plugin-macros": "2 || 3"
},
"devDependencies": {
"@types/babel-plugin-macros": "^2.8.5"
}
}
17 changes: 12 additions & 5 deletions packages/macro/src/icu.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import ICUMessageFormat from "./icu"
import ICUMessageFormat, {Token} from "./icu"
import {Identifier} from "@babel/types"

describe("ICU MessageFormat", function () {
it("should collect text message", function () {
const messageFormat = new ICUMessageFormat()
const tokens = [
const tokens: Token[] = [
{
type: "text",
value: "Hello World",
Expand All @@ -19,22 +20,28 @@ describe("ICU MessageFormat", function () {

it("should collect text message with arguments", function () {
const messageFormat = new ICUMessageFormat()
const tokens = [
const tokens: Token[] = [
{
type: "text",
value: "Hello ",
},
{
type: "arg",
name: "name",
value: "Joe",
value: {
type: "Identifier",
name: "Joe",
} as Identifier,
},
]
expect(messageFormat.fromTokens(tokens)).toEqual(
expect.objectContaining({
message: "Hello {name}",
values: {
name: "Joe",
name: {
type: "Identifier",
name: "Joe",
},
},
})
)
Expand Down
230 changes: 132 additions & 98 deletions packages/macro/src/icu.ts
Original file line number Diff line number Diff line change
@@ -1,112 +1,146 @@
import {Expression, isJSXEmptyExpression, JSXElement, Node} from "@babel/types"

const metaOptions = ["id", "comment", "props"]

const escapedMetaOptionsRe = new RegExp(`^_(${metaOptions.join("|")})$`)

export default function ICUMessageFormat() {}

ICUMessageFormat.prototype.fromTokens = function (tokens) {
return (Array.isArray(tokens) ? tokens : [tokens])
.map((token) => this.processToken(token))
.filter(Boolean)
.reduce(
(props, message) => ({
...message,
message: props.message + message.message,
values: { ...props.values, ...message.values },
jsxElements: { ...props.jsxElements, ...message.jsxElements },
}),
{
message: "",
values: {},
jsxElements: {},
}
)
export type ParsedResult = {
message: string,
values?: Record<string, Expression>,
jsxElements?: Record<string, JSXElement>,
}

ICUMessageFormat.prototype.processToken = function (token) {
const jsxElements = {}
export type TextToken = {
type: "text",
value: string;
}
export type ArgToken = {
type: "arg",
value: Expression;
name?: string;

if (token.type === "text") {
return {
message: token.value,
}
} else if (token.type === "arg") {
if (token.value !== undefined && token.value.type === 'JSXEmptyExpression') {
return null;
}
const values =
token.value !== undefined
? {
[token.name]: token.value,
}
: {}
/**
* plural
* select
* selectordinal
*/
format?: string,
options?: {
offset: string,
[icuChoice: string]: string | Tokens,
},
}
export type ElementToken = {
type: "element",
value: JSXElement;
name?: string | number;
children?: Token[],
}
export type Tokens = Token | Token[];
export type Token = TextToken | ArgToken | ElementToken

switch (token.format) {
case "plural":
case "select":
case "selectordinal":
const formatOptions = Object.keys(token.options)
.filter((key) => token.options[key] != null)
.map((key) => {
let value = token.options[key]
key = key.replace(escapedMetaOptionsRe, "$1")

if (key === "offset") {
// offset has special syntax `offset:number`
return `offset:${value}`
export default class ICUMessageFormat {
public fromTokens(tokens: Tokens): ParsedResult {
return (Array.isArray(tokens) ? tokens : [tokens])
.map((token) => this.processToken(token))
.filter(Boolean)
.reduce(
(props, message) => ({
...message,
message: props.message + message.message,
values: { ...props.values, ...message.values },
jsxElements: { ...props.jsxElements, ...message.jsxElements },
}),
{
message: "",
values: {},
jsxElements: {},
}
)
}

if (typeof value !== "string") {
// process tokens from nested formatters
const {
message,
values: childValues,
jsxElements: childJsxElements,
} = this.fromTokens(value)

Object.assign(values, childValues)
Object.assign(jsxElements, childJsxElements)
value = message
}
public processToken(token: Token): ParsedResult {
const jsxElements: ParsedResult['jsxElements'] = {}

return `${key} {${value}}`
})
.join(" ")

return {
message: `{${token.name}, ${token.format}, ${formatOptions}}`,
values,
jsxElements,
}
default:
return {
message: `{${token.name}}`,
values,
}
}
} else if (token.type === "element") {
let message = ""
let elementValues = {}
Object.assign(jsxElements, { [token.name]: token.value })
token.children.forEach((child) => {
const {
message: childMessage,
values: childValues,
jsxElements: childJsxElements,
} = this.fromTokens(child)

message += childMessage
Object.assign(elementValues, childValues)
Object.assign(jsxElements, childJsxElements)
})
return {
message: token.children.length
? `<${token.name}>${message}</${token.name}>`
: `<${token.name}/>`,
values: elementValues,
jsxElements,
if (token.type === "text") {
return {
message: token.value as string,
}
} else if (token.type === "arg") {
if (token.value !== undefined && isJSXEmptyExpression(token.value as Node)) {
return null;
}
const values = token.value !== undefined
? { [token.name]: token.value }
: {}

switch (token.format) {
case "plural":
case "select":
case "selectordinal":
const formatOptions = Object.keys(token.options)
.filter((key) => token.options[key] != null)
.map((key) => {
let value = token.options[key]
key = key.replace(escapedMetaOptionsRe, "$1")

if (key === "offset") {
// offset has special syntax `offset:number`
return `offset:${value}`
}

if (typeof value !== "string") {
// process tokens from nested formatters
const {
message,
values: childValues,
jsxElements: childJsxElements,
} = this.fromTokens(value)

Object.assign(values, childValues)
Object.assign(jsxElements, childJsxElements)
value = message
}

return `${key} {${value}}`
})
.join(" ")

return {
message: `{${token.name}, ${token.format}, ${formatOptions}}`,
values,
jsxElements,
}
default:
return {
message: `{${token.name}}`,
values,
}
}
} else if (token.type === "element") {
let message = ""
let elementValues: ParsedResult['values'] = {}
Object.assign(jsxElements, { [token.name]: token.value })
token.children.forEach((child) => {
const {
message: childMessage,
values: childValues,
jsxElements: childJsxElements,
} = this.fromTokens(child)

message += childMessage
Object.assign(elementValues, childValues)
Object.assign(jsxElements, childJsxElements)
})
return {
message: token.children.length
? `<${token.name}>${message}</${token.name}>`
: `<${token.name}/>`,
values: elementValues,
jsxElements,
}
}
}

throw new Error(`Unknown token type ${token.type}`)
throw new Error(`Unknown token type ${(token as any).type}`)
}
}
26 changes: 14 additions & 12 deletions packages/macro/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createMacro } from "babel-plugin-macros"
import {createMacro, MacroParams} from "babel-plugin-macros"
import { getConfig } from "@lingui/conf"

import MacroJS from "./macroJs"
import MacroJSX from "./macroJsx"
import {NodePath} from "@babel/traverse"
import {ImportDeclaration, isImportSpecifier, isIdentifier} from "@babel/types"

const config = getConfig({ configPath: process.env.LINGUI_CONFIG })

Expand All @@ -25,13 +27,13 @@ const getSymbolSource = (name: 'i18n' | 'Trans'): [source: string, identifier?:
const [i18nImportModule, i18nImportName = "i18n"] = getSymbolSource("i18n")
const [TransImportModule, TransImportName = "Trans"] = getSymbolSource("Trans")

function macro({ references, state, babel }) {
const jsxNodes = []
const jsNodes = []
function macro({ references, state, babel }: MacroParams) {
const jsxNodes: NodePath[] = []
const jsNodes: NodePath[] = []
let needsI18nImport = false

const alreadyVisitedCache = new WeakSet()
const alreadyVisited = (path) => {
const alreadyVisited = (path: NodePath) => {
if (alreadyVisitedCache.has(path)) {
return true
} else {
Expand Down Expand Up @@ -86,7 +88,7 @@ function macro({ references, state, babel }) {
}
}

function addImport(babel, state, module, importName) {
function addImport(babel: MacroParams["babel"], state: MacroParams["state"], module: string, importName: string) {
const { types: t } = babel

const linguiImport = state.file.path.node.body.find(
Expand All @@ -95,15 +97,15 @@ function addImport(babel, state, module, importName) {
importNode.source.value === module &&
// https://github.com/lingui/js-lingui/issues/777
importNode.importKind !== "type"
)
) as ImportDeclaration

const tIdentifier = t.identifier(importName)
// Handle adding the import or altering the existing import
if (linguiImport) {
if (
linguiImport.specifiers.findIndex(
(specifier) =>
specifier.imported && specifier.imported.name === importName
isImportSpecifier(specifier) && isIdentifier(specifier.imported, {name: importName})
) === -1
) {
linguiImport.specifiers.push(t.importSpecifier(tIdentifier, tIdentifier))
Expand All @@ -118,9 +120,9 @@ function addImport(babel, state, module, importName) {
}
}

function isRootPath(allPath) {
return (node) =>
(function traverse(path) {
function isRootPath(allPath: NodePath[]) {
return (node: NodePath) =>
(function traverse(path): boolean {
if (!path.parentPath) {
return true
} else {
Expand All @@ -129,7 +131,7 @@ function isRootPath(allPath) {
})(node)
}

function getMacroType(tagName) {
function getMacroType(tagName: string): string {
switch (tagName) {
case "defineMessage":
case "arg":
Expand Down
Loading