Skip to content

Commit dfafbf0

Browse files
committed
feat: html lang update
1 parent a077110 commit dfafbf0

8 files changed

Lines changed: 367 additions & 119 deletions

File tree

cmp/compiler/src/plugin/transform/__snapshots__/transform.test.ts.snap

Lines changed: 183 additions & 61 deletions
Large diffs are not rendered by default.

cmp/compiler/src/plugin/transform/process-file.ts

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export interface VisitorsInternalState {
4848
needsUnifiedImport: boolean;
4949
/** Track if we need the async API import */
5050
needsAsyncImport: boolean;
51+
/** Track which components need locale from hook (for <html> lang attribute) */
52+
componentsNeedingLocale: Set<string>;
5153
}
5254

5355
function updateWithMetadataState(
@@ -430,6 +432,66 @@ function transformMixedJSXElement(
430432
path.node.children = [tCall];
431433
}
432434

435+
/**
436+
* Inject dynamic locale attribute into <html> elements
437+
* Transforms: <html> → <html lang={locale}>
438+
*
439+
* Only injects if:
440+
* 1. Element is <html>
441+
* 2. No existing lang/language attribute
442+
* 3. We're inside a React component (has locale context)
443+
*/
444+
function injectHtmlLangAttribute(
445+
node: t.JSXElement,
446+
state: VisitorsInternalState,
447+
): void {
448+
const openingElement = node.openingElement;
449+
450+
if (
451+
openingElement.name.type !== "JSXIdentifier" ||
452+
openingElement.name.name !== "html"
453+
) {
454+
return;
455+
}
456+
457+
// Check if lang attribute already exists
458+
const hasLangAttr = openingElement.attributes.some((attr) => {
459+
return (
460+
attr.type === "JSXAttribute" &&
461+
attr.name.type === "JSXIdentifier" &&
462+
(attr.name.name === "lang" || attr.name.name === "language")
463+
);
464+
});
465+
466+
if (hasLangAttr) {
467+
// Already has lang attribute, don't inject
468+
return;
469+
}
470+
471+
// Check if we're inside a component (has access to locale context)
472+
const component = state.componentsStack.at(-1);
473+
if (!component) {
474+
// Not inside a component, can't inject locale
475+
return;
476+
}
477+
478+
// Inject lang={locale} attribute
479+
// This creates: <html lang={locale}>
480+
const langAttr = t.jsxAttribute(
481+
t.jsxIdentifier("lang"),
482+
t.jsxExpressionContainer(t.identifier("locale")),
483+
);
484+
485+
openingElement.attributes.push(langAttr);
486+
487+
logger.debug(
488+
`Injected locale attribute into <html> element in component: ${component.name}`,
489+
);
490+
491+
// Mark that this component needs locale destructured from the hook
492+
state.componentsNeedingLocale.add(component.name);
493+
}
494+
433495
/**
434496
* Transform a JSX text node into a translation call
435497
*/
@@ -475,19 +537,19 @@ export function injectTranslationHook(
475537
component: ComponentEntry,
476538
state: VisitorsInternalState,
477539
): void {
540+
const needsLocale = state.componentsNeedingLocale.has(component.name);
478541
const hashes = state.componentHashes.get(component.name);
479-
if (!hashes || hashes.length === 0) {
542+
if ((!hashes || hashes.length === 0) && !needsLocale) {
480543
return; // No translations needed
481544
}
482545

483-
// Determine which hook to use based on async status
484546
if (component.isAsync) {
485547
// Async component uses getServerTranslations
486-
injectServerHook(path, hashes);
548+
injectServerHook(path, hashes ?? [], needsLocale);
487549
state.needsAsyncImport = true;
488550
} else {
489551
// Non-async component uses unified hook (useTranslation)
490-
injectUnifiedHook(path, hashes);
552+
injectUnifiedHook(path, hashes ?? [], needsLocale);
491553
state.needsUnifiedImport = true;
492554
}
493555
}
@@ -581,6 +643,9 @@ const componentVisitors = {
581643
JSXElement(path: NodePath<t.JSXElement>) {
582644
translateAttributes(path.node, this.visitorState);
583645

646+
// Inject locale attribute into <html> elements for Next.js
647+
injectHtmlLangAttribute(path.node, this.visitorState);
648+
584649
if (shouldSkipTranslationForElement(path.node)) {
585650
path.skip();
586651
return;
@@ -690,6 +755,7 @@ export function processFile(
690755
componentHashes: new Map<string, string[]>(),
691756
needsUnifiedImport: false,
692757
needsAsyncImport: false,
758+
componentsNeedingLocale: new Set<string>(),
693759
};
694760

695761
traverse(ast, programVisitor, undefined, { visitorState: state });

cmp/compiler/src/plugin/transform/transform.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,7 +1329,7 @@ export default function ServerPage() {
13291329
expect(result.code).not.toContain("import { getServerTranslations }");
13301330

13311331
// Should use useTranslation hook (not await getServerTranslations!)
1332-
expect(result.code).toContain("const t = useTranslation(");
1332+
expect(result.code).toContain("useTranslation(");
13331333
expect(result.code).not.toContain("await getServerTranslations");
13341334

13351335
// Function should NOT be made async
@@ -1367,7 +1367,7 @@ export default function ClientPage() {
13671367

13681368
// Should use unified hook (same as server!)
13691369
expect(result.code).toContain("import { useTranslation } from");
1370-
expect(result.code).toContain("const t = useTranslation(");
1370+
expect(result.code).toContain("useTranslation(");
13711371

13721372
// Should NOT use async API
13731373
expect(result.code).not.toContain("getServerTranslations");
@@ -1445,9 +1445,7 @@ export function RegularCard() {
14451445
);
14461446

14471447
// RegularCard uses unified hook
1448-
expect(result.code).toMatch(
1449-
/function RegularCard.*const t = useTranslation/s,
1450-
);
1448+
expect(result.code).toMatch(/function RegularCard.*useTranslation/s);
14511449

14521450
expect(result.code).toMatchSnapshot();
14531451
});

cmp/compiler/src/plugin/transform/utils.ts

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,10 @@ export function constructServerImport(): t.ImportDeclaration {
259259
*/
260260
export function constructServerTranslationHookCall({
261261
hashes,
262+
needsLocale = false,
262263
}: {
263264
hashes: string[];
265+
needsLocale?: boolean;
264266
}): VariableDeclaration {
265267
const optionsProperties = [];
266268

@@ -269,16 +271,29 @@ export function constructServerTranslationHookCall({
269271
);
270272
optionsProperties.push(t.objectProperty(t.identifier("hashes"), hashArray));
271273

274+
const destructureProperties = [
275+
t.objectProperty(
276+
t.identifier("t"),
277+
t.identifier("t"),
278+
false,
279+
true, // shorthand
280+
),
281+
];
282+
283+
if (needsLocale) {
284+
destructureProperties.push(
285+
t.objectProperty(
286+
t.identifier("locale"),
287+
t.identifier("locale"),
288+
false,
289+
true, // shorthand
290+
),
291+
);
292+
}
293+
272294
return t.variableDeclaration("const", [
273295
t.variableDeclarator(
274-
t.objectPattern([
275-
t.objectProperty(
276-
t.identifier("t"),
277-
t.identifier("t"),
278-
false,
279-
true, // shorthand
280-
),
281-
]),
296+
t.objectPattern(destructureProperties),
282297
t.awaitExpression(
283298
t.callExpression(t.identifier("getServerTranslations"), [
284299
t.objectExpression(optionsProperties),
@@ -296,64 +311,80 @@ export function constructServerTranslationHookCall({
296311
* - In Client Components: loads index.ts (uses Context)
297312
*
298313
* This is the new default for non-async components!
314+
*
315+
* @param componentPath
316+
* @param hashes
317+
* @param needsLocale If true, destructures locale from hook: const { t, locale } = ...
299318
*/
300319
export function injectUnifiedHook(
301320
componentPath: NodePath<
302321
t.FunctionDeclaration | t.FunctionExpression | t.ArrowFunctionExpression
303322
>,
304323
hashes: string[],
324+
needsLocale: boolean = false,
305325
): void {
306326
const body = componentPath.get("body");
307327

328+
let blockBody: NodePath<t.BlockStatement> | undefined;
308329
// Handle arrow functions with expression bodies: () => <jsx>
309330
// Convert to block statement: () => { const t = ...; return <jsx>; }
310331
if (!body.isBlockStatement()) {
311332
if (componentPath.isArrowFunctionExpression() && body.isExpression()) {
312333
const returnStatement = t.returnStatement(body.node as t.Expression);
313334
componentPath.node.body = t.blockStatement([returnStatement]);
314-
// TODO (AleksandrSl 30/11/2025): Why do we need this? why not just componentPath.node.body
315335
// Re-get the body after conversion
316-
const newBody = componentPath.get("body") as NodePath<t.BlockStatement>;
317-
318-
const hashArray = t.arrayExpression(
319-
hashes.map((hash) => t.stringLiteral(hash)),
320-
);
321-
322-
const hookCall = t.variableDeclaration("const", [
323-
t.variableDeclarator(
324-
t.identifier("t"),
325-
t.callExpression(t.identifier("useTranslation"), [hashArray]),
326-
),
327-
]);
328-
329-
newBody.node.body.unshift(hookCall);
336+
blockBody = componentPath.get("body") as NodePath<t.BlockStatement>;
330337
}
338+
} else {
339+
blockBody = body;
340+
}
341+
342+
if (!blockBody) {
331343
return;
332344
}
333345

334346
const hashArray = t.arrayExpression(
335347
hashes.map((hash) => t.stringLiteral(hash)),
336348
);
337349

350+
const pattern = t.objectPattern([
351+
t.objectProperty(t.identifier("t"), t.identifier("t"), false, true),
352+
]);
353+
if (needsLocale) {
354+
pattern.properties.push(
355+
t.objectProperty(
356+
t.identifier("locale"),
357+
t.identifier("locale"),
358+
false,
359+
true,
360+
),
361+
);
362+
}
363+
338364
const hookCall = t.variableDeclaration("const", [
339365
t.variableDeclarator(
340-
t.identifier("t"),
366+
pattern,
341367
t.callExpression(t.identifier("useTranslation"), [hashArray]),
342368
),
343369
]);
344370

345-
body.node.body.unshift(hookCall);
371+
blockBody.node.body.unshift(hookCall);
346372
}
347373

348374
/**
349375
* Inject `const { t } = await getServerTranslations([...hashes])` at component start (Server Components)
350376
* Makes the component async if needed
377+
*
378+
* @param componentPath
379+
* @param hashes
380+
* @param needsLocale If true, destructures locale from hook: const { t, locale } = ...
351381
*/
352382
export function injectServerHook(
353383
componentPath: NodePath<
354384
t.FunctionDeclaration | t.FunctionExpression | t.ArrowFunctionExpression
355385
>,
356386
hashes: string[],
387+
needsLocale: boolean = false,
357388
): void {
358389
const body = componentPath.get("body");
359390

@@ -370,6 +401,7 @@ export function injectServerHook(
370401
// Create: const { t } = await getServerTranslations({ ... })
371402
const serverCall = constructServerTranslationHookCall({
372403
hashes,
404+
needsLocale,
373405
});
374406

375407
newBody.node.body.unshift(serverCall);
@@ -384,6 +416,7 @@ export function injectServerHook(
384416
// Create: const { t } = await getServerTranslations({ ... })
385417
const serverCall = constructServerTranslationHookCall({
386418
hashes,
419+
needsLocale,
387420
});
388421

389422
body.node.body.unshift(serverCall);

cmp/compiler/src/react/client/useTranslation.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,22 @@ export const useTranslation: TranslationHook = (hashes: string[]) => {
5353
registerHashes(hashes);
5454
}, [hashes, registerHashes, locale, sourceLocale]);
5555

56-
return useCallback(
57-
(hash: string, source: string, params?: RichTextParams): ReactNode => {
58-
logger.debug(
59-
`Client. The translations for locale ${locale} are: ${JSON.stringify(translations)}`,
60-
);
61-
const text = translations[hash] || source;
56+
return {
57+
t: useCallback(
58+
(hash: string, source: string, params?: RichTextParams): ReactNode => {
59+
logger.debug(
60+
`Client. The translations for locale ${locale} are: ${JSON.stringify(translations)}`,
61+
);
62+
const text = translations[hash] || source;
6263

63-
if (!params) {
64-
return text;
65-
}
64+
if (!params) {
65+
return text;
66+
}
6667

67-
return renderRichText(text, params);
68-
},
69-
[translations, locale, sourceLocale],
70-
);
68+
return renderRichText(text, params);
69+
},
70+
[translations, locale, sourceLocale],
71+
),
72+
locale,
73+
};
7174
};

cmp/compiler/src/react/server/index.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,18 +76,21 @@ export const useTranslation: TranslationHook = (hashes: string[]) => {
7676
`Server. The translations for locale ${locale} are: ${JSON.stringify(translations)}`,
7777
);
7878

79-
return (
80-
hash: string,
81-
source: string,
82-
params?: RichTextParams,
83-
): string | ReactNode => {
84-
const text = translations[hash] || source;
79+
return {
80+
t: (
81+
hash: string,
82+
source: string,
83+
params?: RichTextParams,
84+
): string | ReactNode => {
85+
const text = translations[hash] || source;
8586

86-
if (!params) {
87-
return text;
88-
}
87+
if (!params) {
88+
return text;
89+
}
8990

90-
return renderRichText(text, params);
91+
return renderRichText(text, params);
92+
},
93+
locale,
9194
};
9295
};
9396

0 commit comments

Comments
 (0)