Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
196 changes: 122 additions & 74 deletions packages/compiler/src/jsx-scope-inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ export const lingoJsxScopeInjectMutation = createCodeMutation((payload) => {
if (skip) {
continue;
}

// Get the original JSX element name
const originalJsxElementName = getJsxElementName(jsxScope);
if (!originalJsxElementName) {
continue;
}

// Check if this is a component (uppercase or member expression)
const isMemberExpression = originalJsxElementName.includes(".");
const isComponent = /^[A-Z]/.test(originalJsxElementName);
const isReactComponent = isMemberExpression || isComponent;
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

Member expression detection using includes('.') is fragile. For HTML elements like <svg.circle> (though uncommon), this would incorrectly identify them as React components. Also, member expressions where the root object starts with lowercase (e.g., lib.Component) would be incorrectly classified as React components. Consider checking if the first character after the last dot is uppercase, or if the base name is uppercase.

Copilot uses AI. Check for mistakes.

// Import LingoComponent based on the module execution mode
const packagePath =
mode === "client" ? ModuleId.ReactClient : ModuleId.ReactRSC;
Expand All @@ -31,88 +43,124 @@ export const lingoJsxScopeInjectMutation = createCodeMutation((payload) => {
exportedName: "LingoComponent",
});

// Get the original JSX element name
const originalJsxElementName = getJsxElementName(jsxScope);
if (!originalJsxElementName) {
continue;
}
if (isReactComponent) {
// For React components, wrap children instead of replacing the component
// This preserves the component type for React.Children APIs
const lingoTextImport = getOrCreateImport(payload.ast, {
moduleName: packagePath,
exportedName: "LingoText",
});

// Create new JSXElement with original attributes
const newNode = t.jsxElement(
t.jsxOpeningElement(
t.jsxIdentifier(lingoComponentImport.importedName),
jsxScope.node.openingElement.attributes.slice(), // original attributes
true, // selfClosing
),
null, // no closing element
[], // no children
true, // selfClosing
);

// Create a NodePath wrapper for the new node to use setJsxAttributeValue
const newNodePath = {
node: newNode,
} as any;

// Add $as prop
const as = /^[A-Z]/.test(originalJsxElementName)
? t.identifier(originalJsxElementName)
: originalJsxElementName;
setJsxAttributeValue(newNodePath, "$as", as);

// Add $fileKey prop
setJsxAttributeValue(newNodePath, "$fileKey", payload.relativeFilePath);

// Add $entryKey prop
setJsxAttributeValue(
newNodePath,
"$entryKey",
getJsxScopeAttribute(jsxScope)!,
);

// Extract $variables from original JSX scope before lingo component was inserted
const $variables = getJsxVariables(jsxScope);
if ($variables.properties.length > 0) {
setJsxAttributeValue(newNodePath, "$variables", $variables);
}
// Create LingoText element with translation data
const lingoTextNode = t.jsxElement(
t.jsxOpeningElement(
t.jsxIdentifier(lingoTextImport.importedName),
[],
true,
),
null,
[],
true,
);

// Extract nested JSX elements
const $elements = getNestedJsxElements(jsxScope);
if ($elements.elements.length > 0) {
setJsxAttributeValue(newNodePath, "$elements", $elements);
}
const lingoTextNodePath = { node: lingoTextNode } as any;

// Extract nested functions
const $functions = getJsxFunctions(jsxScope);
if ($functions.properties.length > 0) {
setJsxAttributeValue(newNodePath, "$functions", $functions);
}
// Add translation metadata to LingoText
setJsxAttributeValue(lingoTextNodePath, "$fileKey", payload.relativeFilePath);
setJsxAttributeValue(lingoTextNodePath, "$entryKey", getJsxScopeAttribute(jsxScope)!);

// Extract expressions
const $expressions = getJsxExpressions(jsxScope);
if ($expressions.elements.length > 0) {
setJsxAttributeValue(newNodePath, "$expressions", $expressions);
}
const $variables = getJsxVariables(jsxScope);
if ($variables.properties.length > 0) {
setJsxAttributeValue(lingoTextNodePath, "$variables", $variables);
}

if (mode === "server") {
// Add $loadDictionary prop
const loadDictionaryImport = getOrCreateImport(payload.ast, {
exportedName: "loadDictionary",
moduleName: ModuleId.ReactRSC,
});
setJsxAttributeValue(
newNodePath,
"$loadDictionary",
t.arrowFunctionExpression(
[t.identifier("locale")],
t.callExpression(t.identifier(loadDictionaryImport.importedName), [
t.identifier("locale"),
]),
const $functions = getJsxFunctions(jsxScope);
if ($functions.properties.length > 0) {
setJsxAttributeValue(lingoTextNodePath, "$functions", $functions);
}

const $expressions = getJsxExpressions(jsxScope);
if ($expressions.elements.length > 0) {
setJsxAttributeValue(lingoTextNodePath, "$expressions", $expressions);
}

if (mode === "server") {
const loadDictionaryImport = getOrCreateImport(payload.ast, {
exportedName: "loadDictionary",
moduleName: ModuleId.ReactRSC,
});
setJsxAttributeValue(
lingoTextNodePath,
"$loadDictionary",
t.arrowFunctionExpression(
[t.identifier("locale")],
t.callExpression(t.identifier(loadDictionaryImport.importedName), [
t.identifier("locale"),
]),
),
);
}

// Replace only the text children with LingoText, keeping the component wrapper
jsxScope.node.children = [lingoTextNode];
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

Replacing all children with only LingoText loses nested JSX elements. When React components have nested elements like <Tab>Hello <strong>world</strong></Tab>, only the text will be preserved, but the <strong> element will be lost. Consider extracting and preserving nested elements using getNestedJsxElements() similar to the HTML elements branch (line 130).

Suggested change
jsxScope.node.children = [lingoTextNode];
// Preserve nested JSX elements, only wrap text nodes in LingoText
const newChildren = [];
for (const child of jsxScope.node.children) {
if (
t.isJSXText(child) ||
(t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression))
) {
// Wrap text or expression in LingoText
// Clone the lingoTextNode to avoid sharing the same node instance
const lingoTextNodeClone = t.jsxElement(
t.jsxOpeningElement(
t.jsxIdentifier(lingoTextImport.importedName),
[],
true,
),
null,
[],
true,
);
const lingoTextNodePathClone = { node: lingoTextNodeClone } as any;
setJsxAttributeValue(lingoTextNodePathClone, "$fileKey", payload.relativeFilePath);
setJsxAttributeValue(lingoTextNodePathClone, "$entryKey", getJsxScopeAttribute(jsxScope)!);
const $variables = getJsxVariables(jsxScope);
if ($variables.properties.length > 0) {
setJsxAttributeValue(lingoTextNodePathClone, "$variables", $variables);
}
const $functions = getJsxFunctions(jsxScope);
if ($functions.properties.length > 0) {
setJsxAttributeValue(lingoTextNodePathClone, "$functions", $functions);
}
const $expressions = getJsxExpressions(jsxScope);
if ($expressions.elements.length > 0) {
setJsxAttributeValue(lingoTextNodePathClone, "$expressions", $expressions);
}
if (mode === "server") {
const loadDictionaryImport = getOrCreateImport(payload.ast, {
exportedName: "loadDictionary",
moduleName: ModuleId.ReactRSC,
});
setJsxAttributeValue(
lingoTextNodePathClone,
"$loadDictionary",
t.arrowFunctionExpression(
[t.identifier("locale")],
t.callExpression(t.identifier(loadDictionaryImport.importedName), [
t.identifier("locale"),
]),
),
);
}
// Place the original child as the child of LingoText
lingoTextNodeClone.children = [child];
newChildren.push(lingoTextNodeClone);
} else {
// Preserve nested JSX elements as is
newChildren.push(child);
}
}
jsxScope.node.children = newChildren;

Copilot uses AI. Check for mistakes.
} else {
// For HTML elements, use the existing approach (replace entire element)
const newNode = t.jsxElement(
t.jsxOpeningElement(
t.jsxIdentifier(lingoComponentImport.importedName),
jsxScope.node.openingElement.attributes.slice(),
true,
),
null,
[],
true,
);
}

jsxScope.replaceWith(newNode);
const newNodePath = { node: newNode } as any;

setJsxAttributeValue(newNodePath, "$as", originalJsxElementName);
setJsxAttributeValue(newNodePath, "$fileKey", payload.relativeFilePath);
setJsxAttributeValue(newNodePath, "$entryKey", getJsxScopeAttribute(jsxScope)!);

const $variables = getJsxVariables(jsxScope);
if ($variables.properties.length > 0) {
setJsxAttributeValue(newNodePath, "$variables", $variables);
}

const $elements = getNestedJsxElements(jsxScope);
if ($elements.elements.length > 0) {
setJsxAttributeValue(newNodePath, "$elements", $elements);
}

const $functions = getJsxFunctions(jsxScope);
if ($functions.properties.length > 0) {
setJsxAttributeValue(newNodePath, "$functions", $functions);
}

const $expressions = getJsxExpressions(jsxScope);
if ($expressions.elements.length > 0) {
setJsxAttributeValue(newNodePath, "$expressions", $expressions);
}

if (mode === "server") {
const loadDictionaryImport = getOrCreateImport(payload.ast, {
exportedName: "loadDictionary",
moduleName: ModuleId.ReactRSC,
});
setJsxAttributeValue(
newNodePath,
"$loadDictionary",
t.arrowFunctionExpression(
[t.identifier("locale")],
t.callExpression(t.identifier(loadDictionaryImport.importedName), [
t.identifier("locale"),
]),
),
);
}

jsxScope.replaceWith(newNode);
}
}

return payload;
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./loader";
export * from "./context";
export * from "./provider";
export * from "./component";
export * from "./text-component";
export * from "./locale-switcher";
export * from "./attribute-component";
export * from "./locale";
62 changes: 62 additions & 0 deletions packages/react/src/client/text-component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

import { useLingo } from "./context";

export type LingoTextProps = {
$fileKey: string;
$entryKey: string;
$variables?: Record<string, any>;
$functions?: Record<string, Function>;
$expressions?: any[];
};

export function LingoText(props: LingoTextProps) {
const { $fileKey, $entryKey, $variables, $functions, $expressions } = props;
const lingo = useLingo();

const translation = getTranslation(
lingo.dictionary,
$fileKey,
$entryKey,
$variables,
$functions,
$expressions
);

// Return just the text, no wrapping element
// This preserves the parent component structure for React.Children APIs
return <>{translation}</>;
}

function getTranslation(
dictionary: any,
fileKey: string,
entryKey: string,
variables?: Record<string, any>,
functions?: Record<string, Function>,
expressions?: any[]
): string {
if (!dictionary) return "";

const entry = dictionary.data?.[fileKey]?.[entryKey];
if (!entry) return "";

let text = entry;

// Replace variables
if (variables) {
Object.entries(variables).forEach(([key, value]) => {
text = text.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

The variable key from Object.entries(variables) is used directly in a RegExp constructor without escaping special regex characters. If a key contains regex metacharacters (e.g., $, ., *), it could cause regex errors or unexpected behavior. Use a regex escape function or escape special characters before constructing the RegExp.

Copilot uses AI. Check for mistakes.
});
}

// Handle expressions
if (expressions) {
expressions.forEach((expr, index) => {
text = text.replace(`{${index}}`, String(expr));
});
}

return text;
}

1 change: 1 addition & 0 deletions packages/react/src/rsc/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./loader";
export * from "./component";
export * from "./text-component";
export * from "./provider";
export * from "./utils";
export * from "./attribute-component";
61 changes: 61 additions & 0 deletions packages/react/src/rsc/text-component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
export type LingoTextProps = {
$fileKey: string;
$entryKey: string;
$loadDictionary: (locale: string) => Promise<any>;
$variables?: Record<string, any>;
$functions?: Record<string, Function>;
$expressions?: any[];
};

export async function LingoText(props: LingoTextProps) {
const { $fileKey, $entryKey, $loadDictionary, $variables, $functions, $expressions } = props;

// Load dictionary on server
const dictionary = await $loadDictionary("default");

const translation = getTranslation(
dictionary,
$fileKey,
$entryKey,
$variables,
$functions,
$expressions
);

// Return just the text, no wrapping element
// This preserves the parent component structure for React.Children APIs
return <>{translation}</>;
}

function getTranslation(
dictionary: any,
fileKey: string,
entryKey: string,
variables?: Record<string, any>,
functions?: Record<string, Function>,
expressions?: any[]
): string {
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

The getTranslation function is duplicated across both text-component.tsx files (RSC and client) with identical implementation. Consider extracting this to a shared utility module to reduce code duplication and ensure consistency.

Copilot uses AI. Check for mistakes.
if (!dictionary) return "";

const entry = dictionary.data?.[fileKey]?.[entryKey];
if (!entry) return "";

let text = entry;

// Replace variables
if (variables) {
Object.entries(variables).forEach(([key, value]) => {
text = text.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

The variable key from Object.entries(variables) is used directly in a RegExp constructor without escaping special regex characters. If a key contains regex metacharacters (e.g., $, ., *), it could cause regex errors or unexpected behavior. Use a regex escape function or escape special characters before constructing the RegExp.

Copilot uses AI. Check for mistakes.
});
}

// Handle expressions
if (expressions) {
expressions.forEach((expr, index) => {
text = text.replace(`{${index}}`, String(expr));
});
}

Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

The function $functions parameter is accepted but never used in the translation logic. If functions are intended to be supported (as the prop exists), they should be processed; otherwise, the parameter should be removed or documented as unsupported.

Suggested change
// Handle function placeholders: {fn:functionName}
if (functions) {
text = text.replace(/\{fn:([a-zA-Z0-9_]+)\}/g, (match, fnName) => {
if (typeof functions[fnName] === "function") {
// Optionally, pass variables/expressions as arguments if needed
try {
return String(functions[fnName](variables, expressions));
} catch (e) {
return "";
}
}
return "";
});
}

Copilot uses AI. Check for mistakes.
return text;
}

Loading