feat(SW-706): add Lokalise tooling and codemod

This commit is contained in:
Michael Zetterberg
2025-03-12 07:02:49 +01:00
parent 1c5b116ed8
commit e22fc1f3c8
26 changed files with 1478 additions and 130 deletions

View File

@@ -0,0 +1,14 @@
/* eslint-disable formatjs/enforce-description */
/* eslint-disable formatjs/enforce-default-message */
/* eslint-disable formatjs/no-id */
import { getIntl } from "@/i18n"
export default async function ServerComponentWithIntl() {
const intl = await getIntl()
return <h1>{intl.formatMessage({
defaultMessage: "some value goes here",
description: {}
})}</h1>
}

View File

@@ -0,0 +1,27 @@
/* eslint-disable formatjs/enforce-description */
/* eslint-disable formatjs/enforce-default-message */
/* eslint-disable formatjs/no-id */
import { getIntl } from "@/i18n"
export default async function ServerComponentWithTernaryInside() {
const intl = await getIntl()
const someId = 'someId'
const data = {
type: 'something'
}
return (
<h1>
{intl.formatMessage({
defaultMessage: someId,
description: {}
})}
{intl.formatMessage({
defaultMessage: data.type,
description: {}
})}
</h1>
)
}

View File

@@ -0,0 +1,21 @@
/* eslint-disable formatjs/enforce-description */
/* eslint-disable formatjs/enforce-default-message */
/* eslint-disable formatjs/no-id */
import { getIntl } from "@/i18n"
export default async function ServerComponentWithTernaryInside() {
const intl = await getIntl()
return (
<h1>
{true ? intl.formatMessage({
defaultMessage: "Some true string",
description: {}
}) : intl.formatMessage({
defaultMessage: "Some false string",
description: {}
})}
</h1>
)
}

View File

@@ -0,0 +1,19 @@
/* eslint-disable formatjs/enforce-description */
/* eslint-disable formatjs/enforce-default-message */
/* eslint-disable formatjs/no-id */
import { getIntl } from "@/i18n"
export default async function ServerComponentWithTernaryInside() {
const intl = await getIntl()
const variable = "some value"
return (
<h1>
{intl.formatMessage({
defaultMessage: `String with {variable}`,
description: {}
}, { variable: variable })}
</h1>
)
}

View File

@@ -0,0 +1,11 @@
/* eslint-disable formatjs/enforce-description */
/* eslint-disable formatjs/enforce-default-message */
/* eslint-disable formatjs/no-id */
import { getIntl } from "@/i18n"
export default async function ServerComponentWithIntl() {
const intl = await getIntl()
return <h1>{intl.formatMessage({ id: "some value goes here" })}</h1>
}

View File

@@ -0,0 +1,21 @@
/* eslint-disable formatjs/enforce-description */
/* eslint-disable formatjs/enforce-default-message */
/* eslint-disable formatjs/no-id */
import { getIntl } from "@/i18n"
export default async function ServerComponentWithTernaryInside() {
const intl = await getIntl()
const someId = 'someId'
const data = {
type: 'something'
}
return (
<h1>
{intl.formatMessage({ id: someId })}
{intl.formatMessage({ id: data.type })}
</h1>
)
}

View File

@@ -0,0 +1,17 @@
/* eslint-disable formatjs/enforce-description */
/* eslint-disable formatjs/enforce-default-message */
/* eslint-disable formatjs/no-id */
import { getIntl } from "@/i18n"
export default async function ServerComponentWithTernaryInside() {
const intl = await getIntl()
return (
<h1>
{intl.formatMessage(
true ? { id: "Some true string" } : { id: "Some false string" }
)}
</h1>
)
}

View File

@@ -0,0 +1,16 @@
/* eslint-disable formatjs/enforce-description */
/* eslint-disable formatjs/enforce-default-message */
/* eslint-disable formatjs/no-id */
import { getIntl } from "@/i18n"
export default async function ServerComponentWithTernaryInside() {
const intl = await getIntl()
const variable = "some value"
return (
<h1>
{intl.formatMessage({ id: `String with ${variable}` })}
</h1>
)
}

View File

@@ -0,0 +1,13 @@
// Run the lokalise.ts through Jiti
import { fileURLToPath } from "node:url"
import createJiti from "jiti"
const lokalise = createJiti(fileURLToPath(import.meta.url))("./lokalise.ts")
lokalise.codemod([
"**/*.{ts,tsx}",
"!**/codemods/lokalise/**", // ignore itself
"!**/node_modules/**", // ignore node_modules
])

View File

@@ -0,0 +1,134 @@
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { describe, expect, test } from "@jest/globals"
import { IndentationText, Project } from "ts-morph"
import { processSourceFile } from "./lokalise"
describe("Lokalise codemod", () => {
test("serverComponent: intl.formatMessage with only id", () => {
const input = readFileSync(
join(__dirname, "./fixtures/serverComponentOnlyId.tsx"),
{
encoding: "utf-8",
}
)
const expected = readFileSync(
join(__dirname, "./expectations/serverComponentOnlyId.tsx"),
{
encoding: "utf-8",
}
)
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
},
})
const sourceFile = project.createSourceFile(
"./fixtures/serverComponent.tsx",
input
)
processSourceFile(sourceFile)
const result = sourceFile.getFullText()
expect(result).toBe(expected)
})
test("serverComponent: intl.formatMessage with ternary inside", () => {
const input = readFileSync(
join(__dirname, "./fixtures/serverComponentWithTernaryInside.tsx"),
{
encoding: "utf-8",
}
)
const expected = readFileSync(
join(__dirname, "./expectations/serverComponentWithTernaryInside.tsx"),
{
encoding: "utf-8",
}
)
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
},
})
const sourceFile = project.createSourceFile(
"./fixtures/serverComponentWithTernaryInside.tsx",
input
)
processSourceFile(sourceFile)
const result = sourceFile.getFullText()
expect(result).toBe(expected)
})
test("serverComponent: intl.formatMessage with variables", () => {
const input = readFileSync(
join(__dirname, "./fixtures/serverComponentWithVariables.tsx"),
{
encoding: "utf-8",
}
)
const expected = readFileSync(
join(__dirname, "./expectations/serverComponentWithVariables.tsx"),
{
encoding: "utf-8",
}
)
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
},
})
const sourceFile = project.createSourceFile(
"./fixtures/serverComponentWithVariables.tsx",
input
)
processSourceFile(sourceFile)
const result = sourceFile.getFullText()
expect(result).toBe(expected)
})
test("serverComponent: intl.formatMessage with variable for id", () => {
const input = readFileSync(
join(__dirname, "./fixtures/serverComponentVariableId.tsx"),
{
encoding: "utf-8",
}
)
const expected = readFileSync(
join(__dirname, "./expectations/serverComponentVariableId.tsx"),
{
encoding: "utf-8",
}
)
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
},
})
const sourceFile = project.createSourceFile(
"./fixtures/serverComponentVariableId.tsx",
input
)
processSourceFile(sourceFile)
const result = sourceFile.getFullText()
expect(result).toBe(expected)
})
})

View File

@@ -0,0 +1,228 @@
import {
type Expression,
type Node,
type ObjectLiteralExpression,
Project,
type SourceFile,
ts,
} from "ts-morph"
export function codemod(paths: string) {
const project = new Project()
project.addSourceFilesAtPaths(paths)
project.getSourceFiles().forEach((file) => {
processSourceFile(file)
file.saveSync()
})
}
export function processSourceFile(file: SourceFile): void {
file.getDescendantsOfKind(ts.SyntaxKind.CallExpression).forEach((call) => {
const expression = call.getExpression().getText()
if (expression === "intl.formatMessage") {
const formatMessageArgs = call.getArguments()
if (formatMessageArgs.length > 0) {
const firstFormatMessageArg = formatMessageArgs[0]
// Handle object literal in the first argument
if (
firstFormatMessageArg.getKind() ===
ts.SyntaxKind.ObjectLiteralExpression
) {
const expressionVariables = getVariableArguments(
firstFormatMessageArg.asKindOrThrow(
ts.SyntaxKind.ObjectLiteralExpression
)
)
if (expressionVariables === "{}") {
// No variables
transformObjectLiteral(
firstFormatMessageArg.asKindOrThrow(
ts.SyntaxKind.ObjectLiteralExpression
)
)
} else {
// Handle variables
const expressionReplacement = `intl.formatMessage(${transformObjectLiteralAndReturn(
firstFormatMessageArg.asKindOrThrow(
ts.SyntaxKind.ObjectLiteralExpression
)
)}, ${expressionVariables})`
call.replaceWithText(expressionReplacement)
}
}
// Handle ternary expressions in the first argument
else if (
firstFormatMessageArg.getKind() ===
ts.SyntaxKind.ConditionalExpression
) {
const conditional = firstFormatMessageArg.asKindOrThrow(
ts.SyntaxKind.ConditionalExpression
)
const whenTrue = conditional.getWhenTrue()
const whenFalse = conditional.getWhenFalse()
// Check for variables in message
const varArgsWhenTrue = getVariableArguments(whenTrue)
const varArgsWhenFalse = getVariableArguments(whenFalse)
// Replacements
const whenTrueReplacement =
varArgsWhenTrue !== "{}"
? `intl.formatMessage(${transformObjectLiteralAndReturn(whenTrue)}, ${varArgsWhenTrue})`
: `intl.formatMessage(${transformObjectLiteralAndReturn(whenTrue)})`
const whenFalseReplacement =
varArgsWhenFalse !== "{}"
? `intl.formatMessage(${transformObjectLiteralAndReturn(whenFalse)}, ${varArgsWhenFalse})`
: `intl.formatMessage(${transformObjectLiteralAndReturn(whenFalse)})`
// Replace the ternary expression
call.replaceWithText(
`${conditional.getCondition().getText()} ? ${whenTrueReplacement} : ${whenFalseReplacement}`
)
}
}
}
})
// Format the file using its existing formatting rules
file.formatText({
indentSize: 2,
})
}
// Helper function to transform object literals
function transformObjectLiteral(objectLiteral: ObjectLiteralExpression): void {
const idProperty = objectLiteral
.getProperties()
.find((prop) => {
const p = prop.asKindOrThrow(ts.SyntaxKind.PropertyAssignment)
return p.getName() === "id"
})
?.asKindOrThrow(ts.SyntaxKind.PropertyAssignment)
if (idProperty) {
const idValue = idProperty.getInitializer()?.getText()
if (idValue) {
// Add defaultMessage
if (
!objectLiteral
.getProperties()
.some(
(prop) => "getName" in prop && prop.getName() === "defaultMessage"
)
) {
objectLiteral.addPropertyAssignment({
name: "defaultMessage",
initializer: idValue,
})
}
// Remove the id property
idProperty.remove()
}
}
// Add description if not present
// if (
// !objectLiteral
// .getProperties()
// .some((prop) => "getName" in prop && prop.getName() === "description")
// ) {
// objectLiteral.addPropertyAssignment({
// name: "description",
// initializer: `{}`,
// })
// }
// Extract variables from the defaultMessage if present
const defaultMessageProp = objectLiteral
.getProperties()
.find((prop) => "getName" in prop && prop.getName() === "defaultMessage")
?.asKindOrThrow(ts.SyntaxKind.PropertyAssignment)
if (defaultMessageProp) {
const defaultMessageValue = defaultMessageProp.getInitializer()?.getText()
if (defaultMessageValue) {
const extractedVariables =
extractVariablesFromTemplateString(defaultMessageValue)
if (extractedVariables.length > 0) {
// Replace the variables in the defaultMessage with FormatJS placeholders
const transformedMessage = replaceWithFormatJSPlaceholders(
defaultMessageValue,
extractedVariables
)
defaultMessageProp.setInitializer(`${transformedMessage}`)
}
}
}
}
// Helper function to extract variables from a template string
function extractVariablesFromTemplateString(templateString: string): string[] {
const regex = /\${(.*?)}/g
const variables: string[] = []
let match
// Find all variable references in the template literal
while ((match = regex.exec(templateString)) !== null) {
variables.push(match[1].trim())
}
return variables
}
// Helper function to replace variables with FormatJS placeholders
function replaceWithFormatJSPlaceholders(
templateString: string,
variables: string[]
): string {
let transformedMessage = templateString
variables.forEach((variable) => {
transformedMessage = transformedMessage.replace(
`\${${variable}}`,
`{${variable}}`
)
})
return transformedMessage
}
// Helper function to get the variables from the ternary branch
function getVariableArguments(exp: Expression): string {
const idProp = exp
.asKindOrThrow(ts.SyntaxKind.ObjectLiteralExpression)
.getProperties()
.find((prop) => "getName" in prop && prop.getName() === "id")
?.asKindOrThrow(ts.SyntaxKind.PropertyAssignment)
if (idProp) {
const extractedVariables = extractVariablesFromTemplateString(
idProp.getText()
)
if (extractedVariables.length > 0) {
return `{ ${extractedVariables.map((v) => `${v}: ${v}`).join(", ")} }`
}
}
return "{}" // Return empty if no variables found
}
// Helper function to transform object literals and return text
function transformObjectLiteralAndReturn(object: Node): string {
if (object.getKind() === ts.SyntaxKind.ObjectLiteralExpression) {
const obj = object.asKindOrThrow(ts.SyntaxKind.ObjectLiteralExpression)
transformObjectLiteral(obj)
return obj.getText() // Return the transformed object literal as text
}
return object.getText()
}

View File

@@ -0,0 +1,27 @@
/* eslint-disable formatjs/enforce-description */
/* eslint-disable formatjs/enforce-default-message */
/* eslint-disable formatjs/no-id */
import { getIntl } from "@/i18n"
export default async function ServerComponentWithIntl() {
const intl = await getIntl()
const variable = "some value"
return (
<h1>
{true
? intl.formatMessage({
defaultMessage: "Some string",
description: {},
})
: intl.formatMessage(
{
defaultMessage: `Other string {variable}`,
description: {},
},
{ variable: variable }
)}
</h1>
)
}