From 23455d6564f3621640c3f019d009c03a00b64978 Mon Sep 17 00:00:00 2001 From: Ogrodev Date: Wed, 3 Jun 2026 20:07:30 -0300 Subject: [PATCH] Fix TranslationFn helper extraction docs --- apps/website/public/_tyndale/es.json | 4 +- apps/website/public/_tyndale/fr.json | 4 +- apps/website/public/_tyndale/ja.json | 20 ++--- apps/website/public/_tyndale/ko.json | 14 ++-- apps/website/public/_tyndale/zh.json | 24 +++--- .../src/content/docs/reference/react-api.mdx | 41 ++++++++++- packages/tyndale-react/README.md | 57 +++++++++++++-- packages/tyndale-react/src/get-translation.ts | 8 +- packages/tyndale-react/src/index.ts | 2 + packages/tyndale-react/src/server.ts | 1 + packages/tyndale-react/src/types.ts | 14 ++++ packages/tyndale-react/src/use-translation.ts | 10 +-- .../tests/__fixtures__/translation-fn-type.ts | 20 +++++ .../tyndale-react/tests/type-exports.test.ts | 40 ++++++++++ .../tyndale/src/extract/string-extractor.ts | 46 ++++++++---- .../__fixtures__/project/src/metadata.ts | 14 ++++ .../tyndale/tests/commands/extract.test.ts | 8 +- .../tests/extract/string-extractor.test.ts | 73 ++++++++++++++++++- 18 files changed, 324 insertions(+), 76 deletions(-) create mode 100644 packages/tyndale-react/tests/__fixtures__/translation-fn-type.ts create mode 100644 packages/tyndale-react/tests/type-exports.test.ts create mode 100644 packages/tyndale/tests/commands/__fixtures__/project/src/metadata.ts diff --git a/apps/website/public/_tyndale/es.json b/apps/website/public/_tyndale/es.json index abaff64..fe1d57f 100644 --- a/apps/website/public/_tyndale/es.json +++ b/apps/website/public/_tyndale/es.json @@ -1,8 +1,8 @@ { "0421d7c92bd287ac2a8e21e86010189de2d31c64de0e59ed45550f57c4860168": "Con IA", - "17e440c67893643336f3d1f557d9444ac428db15a432166beea379fa041aa1d5": "Escribir tu componente", + "17e440c67893643336f3d1f557d9444ac428db15a432166beea379fa041aa1d5": "Escribe tu componente", "1a182a7c6ac1952813b79d7120f57a1dba0936b29ccaa43866a38d0532980395": "tyndale validate verifica las traducciones sin escribir archivos.", - "1b9e2dc0195e5f890cf7a3d74cb7455cba7d3e721ef7bd122e56069ccb8396fe": "Cambia de idioma y mira cómo el contenido se actualiza al instante. Esto es lo que experimentan tus usuarios.", + "1b9e2dc0195e5f890cf7a3d74cb7455cba7d3e721ef7bd122e56069ccb8396fe": "Cambia de idioma y observa cómo el contenido se actualiza al instante. Esto es lo que experimentan tus usuarios.", "20672423d71690888afb7caa922ea2a23592d49346341b1eee292de65189a296": "Ver en GitHub", "24cab632471e2475f5ce9e98a165454917efced9a13ca2c6533f157a1f07b595": "¿Listo para lanzar tu app globalmente?", "268339482922bb3ed004fa3cf1a0df2a98550d5a212bc7ce1424b74e23e41233": "Tres pasos para ser global", diff --git a/apps/website/public/_tyndale/fr.json b/apps/website/public/_tyndale/fr.json index 79a02f9..5dd8561 100644 --- a/apps/website/public/_tyndale/fr.json +++ b/apps/website/public/_tyndale/fr.json @@ -1,8 +1,8 @@ { - "0421d7c92bd287ac2a8e21e86010189de2d31c64de0e59ed45550f57c4860168": "Propulsé par l’IA", + "0421d7c92bd287ac2a8e21e86010189de2d31c64de0e59ed45550f57c4860168": "propulsé par l’IA", "17e440c67893643336f3d1f557d9444ac428db15a432166beea379fa041aa1d5": "Écrire votre composant", "1a182a7c6ac1952813b79d7120f57a1dba0936b29ccaa43866a38d0532980395": "tyndale validate vérifie les traductions sans écrire de fichiers.", - "1b9e2dc0195e5f890cf7a3d74cb7455cba7d3e721ef7bd122e56069ccb8396fe": "Changez de langue et voyez le contenu se mettre à jour instantanément. C’est l’expérience de vos utilisateurs.", + "1b9e2dc0195e5f890cf7a3d74cb7455cba7d3e721ef7bd122e56069ccb8396fe": "Changez de langue et voyez le contenu se mettre à jour instantanément. C’est ce que vivent vos utilisateurs.", "20672423d71690888afb7caa922ea2a23592d49346341b1eee292de65189a296": "Voir sur GitHub", "24cab632471e2475f5ce9e98a165454917efced9a13ca2c6533f157a1f07b595": "Prêt à passer à l’international ?", "268339482922bb3ed004fa3cf1a0df2a98550d5a212bc7ce1424b74e23e41233": "Trois étapes pour passer à l’international", diff --git a/apps/website/public/_tyndale/ja.json b/apps/website/public/_tyndale/ja.json index 328e171..9c2d48d 100644 --- a/apps/website/public/_tyndale/ja.json +++ b/apps/website/public/_tyndale/ja.json @@ -1,8 +1,8 @@ { "0421d7c92bd287ac2a8e21e86010189de2d31c64de0e59ed45550f57c4860168": "AI搭載", "17e440c67893643336f3d1f557d9444ac428db15a432166beea379fa041aa1d5": "コンポーネントを作成", - "1a182a7c6ac1952813b79d7120f57a1dba0936b29ccaa43866a38d0532980395": "tyndale validate は、ファイルを書き込まずに翻訳を検証します。", - "1b9e2dc0195e5f890cf7a3d74cb7455cba7d3e721ef7bd122e56069ccb8396fe": "ロケールを切り替えると、コンテンツが即座に更新されます。これがユーザーの体験です。", + "1a182a7c6ac1952813b79d7120f57a1dba0936b29ccaa43866a38d0532980395": "tyndale validate はファイルを書き込まずに翻訳をチェックします。", + "1b9e2dc0195e5f890cf7a3d74cb7455cba7d3e721ef7bd122e56069ccb8396fe": "ロケールを切り替えると、コンテンツが即座に更新されます。これが実際のユーザー体験です。", "20672423d71690888afb7caa922ea2a23592d49346341b1eee292de65189a296": "GitHubで見る", "24cab632471e2475f5ce9e98a165454917efced9a13ca2c6533f157a1f07b595": "グローバル展開の準備はできていますか?", "268339482922bb3ed004fa3cf1a0df2a98550d5a212bc7ce1424b74e23e41233": "グローバル展開まで3ステップ", @@ -16,20 +16,20 @@ "72c1a3fcc60022a54e18695b9339cbabfd0d0c5323d67023b7ce2f33a8034ab4": "Middleware、プロバイダー、RTL、静的生成。", "779cfd1ef832e29770721098eb3f3b5c3285cac36b48cceaff699fadf34f1b10": "キーファイル不要のワークフロー", "7af023c43013b9a53fbff7dd4b5821588bba3319308878229740489152c43f6d": "ドキュメント", - "7e9b6873f872d858db85be10a071a8c0c7cac6e30d33e7273fd58a156a0c2ee4": "前回の実行以降に変更された部分だけを翻訳します。", + "7e9b6873f872d858db85be10a071a8c0c7cac6e30d33e7273fd58a156a0c2ee4": "前回の実行から変更された箇所だけを翻訳します。", "8093606f5dea8770c10c7158bdc4d086f4236899c1a9d92c994941ee6db117b5": "コンポーネントを作成。CLIを実行。翻訳してリリース。", - "8261458cf5c7081cfa52fa99cd937affef77739d87f2d683ea8e6a4d84b4f76f": "キーファイルを管理せずに、JSXと文字列を翻訳。", - "848832860d55c6a64e25e21f86b910b6e6a5538aeebaf187a2ce6f504f9eaa0a": "今日からReactアプリの翻訳を始めましょう。オープンソースで、永久無料です。", + "8261458cf5c7081cfa52fa99cd937affef77739d87f2d683ea8e6a4d84b4f76f": "キーファイルを管理せずに、JSXと文字列を翻訳できます。", + "848832860d55c6a64e25e21f86b910b6e6a5538aeebaf187a2ce6f504f9eaa0a": "今すぐReactアプリの翻訳を始めましょう。オープンソースで、ずっと無料です。", "9000a7b3a28531fc48a5eec38524ec3ce0e526c770f4c49a62c9d6bf9dd39cb4": "GitHubでスターを付ける", "9478ae513b72a79e8af8ede569d13ca035bd620dd82d07ccb61f793be989e12d": "Next.jsファーストクラス対応", - "96ab624b80ad052cb59be429ceae9f45e5c3a1f1ab695121eadbd7c459290de6": "抽出からデプロイまで、Tyndaleが翻訳ワークフロー全体を処理します。", + "96ab624b80ad052cb59be429ceae9f45e5c3a1f1ab695121eadbd7c459290de6": "抽出からデプロイまで、Tyndaleが翻訳ワークフロー全体を担います。", "983f311018642b0ae60c71253b5735579906d43d260ea926ebe5e815b1c97abb": "はじめる", - "a017add6bb76387e0f7562230c809a2c7e3f0103d49b40969391c293fd0fe1a4": "差分対応", - "a6ca1bf546887fea51c0ec17d6adce63ae510eb61c972d00b26d5c8a90a85e17": "独自のAIプロバイダーを使って、高品質な翻訳を実現します。", + "a017add6bb76387e0f7562230c809a2c7e3f0103d49b40969391c293fd0fe1a4": "差分翻訳", + "a6ca1bf546887fea51c0ec17d6adce63ae510eb61c972d00b26d5c8a90a85e17": "独自のAIプロバイダーを使って高品質な翻訳を実現します。", "af2a0b929b65332983bc9ee1ab366c5ab5d5b481f6578dcfc928087cfcd340ba": "あらゆる言語に対応。", - "d3a58d3cb07abad32cc2311dabd1e68b0ab9e27dd921b398f8cf91ea7c0fdead": "アプリを1つの言語で書く。", + "d3a58d3cb07abad32cc2311dabd1e68b0ab9e27dd921b398f8cf91ea7c0fdead": "1つの言語でアプリを開発。", "f911e414cf6bdfc595532ab166b5ba0f63d73c021452fcdacbda363dda6ad8fb": "GitHub", - "fbd47d140d266df1fd9a5a7372bcf94a16f620b632a5070b6898483cedea6dcc": "ReactとNext.js向けのAI搭載i18n。キーファイル不要。手動翻訳不要。", + "fbd47d140d266df1fd9a5a7372bcf94a16f620b632a5070b6898483cedea6dcc": "React と Next.js 向けのAI搭載i18n。キーファイルは不要。手動翻訳も不要。", "fd9c821056de0e28597fa9be0ff16e3ba34f94f307414dc220c8fc281d1d06bb": "CLIを実行", "fdb627efb86ba73e631686e916f746d4b946f5d2f1594109caf982ca2b694fe2": "オープンソース" } diff --git a/apps/website/public/_tyndale/ko.json b/apps/website/public/_tyndale/ko.json index 7a2d313..24c1c94 100644 --- a/apps/website/public/_tyndale/ko.json +++ b/apps/website/public/_tyndale/ko.json @@ -2,12 +2,12 @@ "0421d7c92bd287ac2a8e21e86010189de2d31c64de0e59ed45550f57c4860168": "AI 기반", "17e440c67893643336f3d1f557d9444ac428db15a432166beea379fa041aa1d5": "컴포넌트 작성하기", "1a182a7c6ac1952813b79d7120f57a1dba0936b29ccaa43866a38d0532980395": "tyndale validate는 파일을 쓰지 않고 번역을 검증합니다.", - "1b9e2dc0195e5f890cf7a3d74cb7455cba7d3e721ef7bd122e56069ccb8396fe": "로케일을 전환하면 콘텐츠가 즉시 업데이트됩니다. 이것이 실제 사용자 경험입니다.", + "1b9e2dc0195e5f890cf7a3d74cb7455cba7d3e721ef7bd122e56069ccb8396fe": "로케일을 전환하면 콘텐츠가 즉시 업데이트됩니다. 사용자는 이렇게 경험합니다.", "20672423d71690888afb7caa922ea2a23592d49346341b1eee292de65189a296": "GitHub에서 보기", "24cab632471e2475f5ce9e98a165454917efced9a13ca2c6533f157a1f07b595": "글로벌 출시할 준비가 되셨나요?", - "268339482922bb3ed004fa3cf1a0df2a98550d5a212bc7ce1424b74e23e41233": "글로벌까지 3단계", - "26ceae0deed7525be24452096952602462eaf982f3353af6932b77ef27af5856": "동작 모습 보기", - "28c0fe2c0fc96d3c0cbcf9eb97ef9f0cd5306151f1ae763a7af877af8421e11d": "변수, 복수형, 숫자, 통화, 날짜를 지원합니다.", + "268339482922bb3ed004fa3cf1a0df2a98550d5a212bc7ce1424b74e23e41233": "글로벌 진출까지 3단계", + "26ceae0deed7525be24452096952602462eaf982f3353af6932b77ef27af5856": "동작 확인하기", + "28c0fe2c0fc96d3c0cbcf9eb97ef9f0cd5306151f1ae763a7af877af8421e11d": "변수, 복수형, 숫자, 통화, 날짜.", "379612d2a3fa8d73230edf55eb75bee687983cda9a9ea70f17b6b89172543dc3": "i18n에 필요한 모든 것", "41e1bf411736680d8b4a06374bf79e157365ab4aa01c176fc63b2ae6e3794a2f": "CI 친화적", "4b1feb84f6c8fc2586dae7d20b113ac07555b72ae9d25b75a84ddf4149bdc241": "풍부한 콘텐츠", @@ -19,9 +19,9 @@ "7e9b6873f872d858db85be10a071a8c0c7cac6e30d33e7273fd58a156a0c2ee4": "마지막 실행 이후 변경된 내용만 번역합니다.", "8093606f5dea8770c10c7158bdc4d086f4236899c1a9d92c994941ee6db117b5": "컴포넌트를 작성하세요. CLI를 실행하세요. 번역된 상태로 배포하세요.", "8261458cf5c7081cfa52fa99cd937affef77739d87f2d683ea8e6a4d84b4f76f": "키 파일을 관리하지 않고 JSX와 문자열을 번역합니다.", - "848832860d55c6a64e25e21f86b910b6e6a5538aeebaf187a2ce6f504f9eaa0a": "오늘 React 앱 번역을 시작하세요. 오픈 소스이며 평생 무료입니다.", + "848832860d55c6a64e25e21f86b910b6e6a5538aeebaf187a2ce6f504f9eaa0a": "오늘 React 앱 번역을 시작하세요. 오픈 소스이며, 영구적으로 무료입니다.", "9000a7b3a28531fc48a5eec38524ec3ce0e526c770f4c49a62c9d6bf9dd39cb4": "GitHub에서 스타 주기", - "9478ae513b72a79e8af8ede569d13ca035bd620dd82d07ccb61f793be989e12d": "Next.js 완전 지원", + "9478ae513b72a79e8af8ede569d13ca035bd620dd82d07ccb61f793be989e12d": "Next.js 우선 지원", "96ab624b80ad052cb59be429ceae9f45e5c3a1f1ab695121eadbd7c459290de6": "추출부터 배포까지, Tyndale이 전체 번역 워크플로를 처리합니다.", "983f311018642b0ae60c71253b5735579906d43d260ea926ebe5e815b1c97abb": "시작하기", "a017add6bb76387e0f7562230c809a2c7e3f0103d49b40969391c293fd0fe1a4": "증분 방식", @@ -29,7 +29,7 @@ "af2a0b929b65332983bc9ee1ab366c5ab5d5b481f6578dcfc928087cfcd340ba": "모든 언어를 지원하세요.", "d3a58d3cb07abad32cc2311dabd1e68b0ab9e27dd921b398f8cf91ea7c0fdead": "앱을 하나의 언어로 작성하세요.", "f911e414cf6bdfc595532ab166b5ba0f63d73c021452fcdacbda363dda6ad8fb": "GitHub", - "fbd47d140d266df1fd9a5a7372bcf94a16f620b632a5070b6898483cedea6dcc": "React & Next.js를 위한 AI 기반 i18n. 키 파일이 필요 없습니다. 수동 번역도 필요 없습니다.", + "fbd47d140d266df1fd9a5a7372bcf94a16f620b632a5070b6898483cedea6dcc": "React & Next.js를 위한 AI 기반 국제화(i18n). 키 파일이 필요 없습니다. 수동 번역도 필요 없습니다.", "fd9c821056de0e28597fa9be0ff16e3ba34f94f307414dc220c8fc281d1d06bb": "CLI 실행하기", "fdb627efb86ba73e631686e916f746d4b946f5d2f1594109caf982ca2b694fe2": "오픈 소스" } diff --git a/apps/website/public/_tyndale/zh.json b/apps/website/public/_tyndale/zh.json index 885c2ae..ea2986c 100644 --- a/apps/website/public/_tyndale/zh.json +++ b/apps/website/public/_tyndale/zh.json @@ -1,8 +1,8 @@ { "0421d7c92bd287ac2a8e21e86010189de2d31c64de0e59ed45550f57c4860168": "AI 驱动", "17e440c67893643336f3d1f557d9444ac428db15a432166beea379fa041aa1d5": "编写组件", - "1a182a7c6ac1952813b79d7120f57a1dba0936b29ccaa43866a38d0532980395": "tyndale validate 在不写入文件的情况下检查翻译。", - "1b9e2dc0195e5f890cf7a3d74cb7455cba7d3e721ef7bd122e56069ccb8396fe": "切换 locales,内容会立即更新。这就是用户的真实体验。", + "1a182a7c6ac1952813b79d7120f57a1dba0936b29ccaa43866a38d0532980395": "tyndale validate 可在不写入文件的情况下检查翻译。", + "1b9e2dc0195e5f890cf7a3d74cb7455cba7d3e721ef7bd122e56069ccb8396fe": "切换 locale,内容会立即更新。这就是用户将体验到的效果。", "20672423d71690888afb7caa922ea2a23592d49346341b1eee292de65189a296": "前往 GitHub 查看", "24cab632471e2475f5ce9e98a165454917efced9a13ca2c6533f157a1f07b595": "准备好走向全球了吗?", "268339482922bb3ed004fa3cf1a0df2a98550d5a212bc7ce1424b74e23e41233": "3 步走向全球", @@ -10,26 +10,26 @@ "28c0fe2c0fc96d3c0cbcf9eb97ef9f0cd5306151f1ae763a7af877af8421e11d": "变量、复数、数字、货币和日期。", "379612d2a3fa8d73230edf55eb75bee687983cda9a9ea70f17b6b89172543dc3": "i18n 所需,一应俱全", "41e1bf411736680d8b4a06374bf79e157365ab4aa01c176fc63b2ae6e3794a2f": "CI 友好", - "4b1feb84f6c8fc2586dae7d20b113ac07555b72ae9d25b75a84ddf4149bdc241": "丰富内容", + "4b1feb84f6c8fc2586dae7d20b113ac07555b72ae9d25b75a84ddf4149bdc241": "丰富内容支持", "5a03dfaf7af7e296ecde33a3e2ca868a1aa58bc1c20a67dac8aa74f3bcf5e6c0": "查看结果", - "70a71fd48a648281f97db640cd17e911b21b7aed6c426946a6ebfca75b5370b1": "开源 AI 驱动 i18n。", + "70a71fd48a648281f97db640cd17e911b21b7aed6c426946a6ebfca75b5370b1": "开源、AI 驱动的 i18n。", "72c1a3fcc60022a54e18695b9339cbabfd0d0c5323d67023b7ce2f33a8034ab4": "中间件、providers、RTL、静态生成。", "779cfd1ef832e29770721098eb3f3b5c3285cac36b48cceaff699fadf34f1b10": "零 key 工作流", "7af023c43013b9a53fbff7dd4b5821588bba3319308878229740489152c43f6d": "文档", - "7e9b6873f872d858db85be10a071a8c0c7cac6e30d33e7273fd58a156a0c2ee4": "仅翻译自上次运行以来发生变化的内容。", + "7e9b6873f872d858db85be10a071a8c0c7cac6e30d33e7273fd58a156a0c2ee4": "只翻译自上次运行以来发生变化的内容。", "8093606f5dea8770c10c7158bdc4d086f4236899c1a9d92c994941ee6db117b5": "编写组件。运行 CLI。发布多语言版本。", "8261458cf5c7081cfa52fa99cd937affef77739d87f2d683ea8e6a4d84b4f76f": "无需维护 key 文件,即可翻译 JSX 和字符串。", - "848832860d55c6a64e25e21f86b910b6e6a5538aeebaf187a2ce6f504f9eaa0a": "今天就开始翻译你的 React 应用。开源,永久免费。", + "848832860d55c6a64e25e21f86b910b6e6a5538aeebaf187a2ce6f504f9eaa0a": "今天就开始为 React 应用做翻译。开源,永久免费。", "9000a7b3a28531fc48a5eec38524ec3ce0e526c770f4c49a62c9d6bf9dd39cb4": "在 GitHub 上 Star", - "9478ae513b72a79e8af8ede569d13ca035bd620dd82d07ccb61f793be989e12d": "Next.js 一等支持", + "9478ae513b72a79e8af8ede569d13ca035bd620dd82d07ccb61f793be989e12d": "Next.js 原生级支持", "96ab624b80ad052cb59be429ceae9f45e5c3a1f1ab695121eadbd7c459290de6": "从提取到部署,Tyndale 处理整个翻译工作流。", "983f311018642b0ae60c71253b5735579906d43d260ea926ebe5e815b1c97abb": "开始使用", - "a017add6bb76387e0f7562230c809a2c7e3f0103d49b40969391c293fd0fe1a4": "增量", - "a6ca1bf546887fea51c0ec17d6adce63ae510eb61c972d00b26d5c8a90a85e17": "使用你自己的 AI 提供商,实现高质量翻译。", - "af2a0b929b65332983bc9ee1ab366c5ab5d5b481f6578dcfc928087cfcd340ba": "覆盖所有语言。", - "d3a58d3cb07abad32cc2311dabd1e68b0ab9e27dd921b398f8cf91ea7c0fdead": "用一种语言编写应用。", + "a017add6bb76387e0f7562230c809a2c7e3f0103d49b40969391c293fd0fe1a4": "增量翻译", + "a6ca1bf546887fea51c0ec17d6adce63ae510eb61c972d00b26d5c8a90a85e17": "使用你自己的 AI 提供商,获得高质量翻译。", + "af2a0b929b65332983bc9ee1ab366c5ab5d5b481f6578dcfc928087cfcd340ba": "获得所有语言版本。", + "d3a58d3cb07abad32cc2311dabd1e68b0ab9e27dd921b398f8cf91ea7c0fdead": "只用一种语言编写应用。", "f911e414cf6bdfc595532ab166b5ba0f63d73c021452fcdacbda363dda6ad8fb": "GitHub", - "fbd47d140d266df1fd9a5a7372bcf94a16f620b632a5070b6898483cedea6dcc": "面向 React 与 Next.js 的 AI 驱动 i18n。无需 key 文件。无需手动翻译。", + "fbd47d140d266df1fd9a5a7372bcf94a16f620b632a5070b6898483cedea6dcc": "面向 React 与 Next.js 的 AI 驱动 i18n(国际化)。无需 key 文件。无需手动翻译。", "fd9c821056de0e28597fa9be0ff16e3ba34f94f307414dc220c8fc281d1d06bb": "运行 CLI", "fdb627efb86ba73e631686e916f746d4b946f5d2f1594109caf982ca2b694fe2": "开源" } diff --git a/apps/website/src/content/docs/reference/react-api.mdx b/apps/website/src/content/docs/reference/react-api.mdx index 753efe4..ac1bc0e 100644 --- a/apps/website/src/content/docs/reference/react-api.mdx +++ b/apps/website/src/content/docs/reference/react-api.mdx @@ -72,9 +72,11 @@ function Greeting({ name }: { name: string }) { Signature: ```ts -(source: string, vars?: Record) => string +type TranslationFn = (source: string, vars?: Record) => string; ``` +`TranslationFn` is exported from `tyndale-react` and is also the return type of `getTranslation()`. + ### `useLocale()` Returns the current locale string. @@ -128,9 +130,9 @@ The hook looks up manifest entries with `type: 'dictionary'` and falls back to t ### `getTranslation(options)` -Async server helper that loads a locale file from disk and returns a translation function. +Async server helper that loads a locale file from disk and returns a `TranslationFn`. -Available from both `tyndale-react` and the server-only subpath: +Import it from the server-only subpath so client bundles never include Node filesystem code: ```ts import { getTranslation } from 'tyndale-react/server'; @@ -178,6 +180,39 @@ export const copy = { Use `msgString()` in non-React contexts such as plain TypeScript modules, Astro frontmatter, or server utilities. +### Pure shared helpers + +For helpers imported by both Server and Client Components, pass a `TranslationFn` into the helper instead of reading translation state globally. Mark module-scope label maps with `msgString()` so the CLI extracts them. + +```ts +import { msgString, type TranslationFn } from 'tyndale-react'; + +type Status = 'valid' | 'expired'; + +const STATUS_LABELS = { + valid: msgString('Compliant'), + expired: msgString('Expired'), +} satisfies Record; + +const STATUS_FILTER_LABEL = msgString('Status: {status}'); + +export function statusLabel(status: Status, t: TranslationFn): string { + return t(STATUS_LABELS[status]); +} + +export function activeFilterLabels(status: Status | undefined, t: TranslationFn): string[] { + const labels: string[] = []; + + if (status) { + labels.push(t(STATUS_FILTER_LABEL, { status: statusLabel(status, t) })); + } + + return labels; +} +``` + +Client components pass `useTranslation()`. Server components pass `await getTranslation(...)`. Translate composed labels with placeholders instead of template literals so word order can change by locale. + ## Translation and formatting components ### `` diff --git a/packages/tyndale-react/README.md b/packages/tyndale-react/README.md index 9aefb3c..9a78c7b 100644 --- a/packages/tyndale-react/README.md +++ b/packages/tyndale-react/README.md @@ -183,6 +183,12 @@ const greeting = t('Hello, {name}!', { name: 'Alice' }); Hashes the source string, looks up the translation, applies interpolation, and falls back to the source when no translation exists. +`useTranslation()` and `getTranslation()` both return the exported `TranslationFn` type: + +```ts +import type { TranslationFn } from 'tyndale-react'; +``` + --- ### `useLocale()` @@ -238,21 +244,23 @@ The CLI extractor recognizes `msg('literal')` calls and extracts the argument. ### `msgString(source)` -Like `msg()`, but for non-React contexts (Astro, Node.js) where a plain string is needed. Returns the source string unchanged at runtime; the CLI extractor still picks it up. +Like `msg()`, but for non-React contexts where a plain string is needed. Returns the source string unchanged at runtime; the CLI extractor still picks it up. Pass the returned source through a `TranslationFn` when you need the translated value. ```ts -import { msgString } from 'tyndale-react'; -const title = msgString('Page title'); +import { msgString, type TranslationFn } from 'tyndale-react'; +declare const t: TranslationFn; +const titleSource = msgString('Page title'); +const title = t(titleSource); ``` --- ### `getTranslation(options)` — server entry (`tyndale-react/server`) -Async server-side translation function. Loads locale files from disk and returns a `t()` function. +Async server-side translation function. Loads locale files from disk and returns a `TranslationFn`. ```ts -import { getTranslation } from 'tyndale-react/server'; +import { getTranslation, type TranslationFn } from 'tyndale-react/server'; const t = await getTranslation({ locale: 'fr', @@ -260,12 +268,49 @@ const t = await getTranslation({ outputPath: './public/_tyndale', }); -const title = t('Welcome'); +const title: string = t('Welcome'); +const sameShape: TranslationFn = t; ``` > [!NOTE] > For Next.js server components, use `TyndaleServerProvider` from [`tyndale-next`](../tyndale-next) instead — it handles file loading and passes translations through React context automatically. +### Pure shared helpers + +For plain TypeScript helpers used by both Server and Client Components, keep the helper pure and inject a `TranslationFn`. Use `msgString()` in module-scope maps so the CLI extracts those source strings, then translate through the injected function at the call site. + +```ts +import { msgString, type TranslationFn } from 'tyndale-react'; + +type DashboardStatus = 'valid' | 'expired'; + +const STATUS_LABELS = { + valid: msgString('Compliant'), + expired: msgString('Expired'), +} satisfies Record; + +const STATUS_FILTER_LABEL = msgString('Status: {status}'); + +export function statusLabel(status: DashboardStatus, t: TranslationFn): string { + return t(STATUS_LABELS[status]); +} + +export function activeFilterLabels( + filters: { status?: DashboardStatus }, + t: TranslationFn, +): string[] { + const labels: string[] = []; + + if (filters.status) { + labels.push(t(STATUS_FILTER_LABEL, { status: statusLabel(filters.status, t) })); + } + + return labels; +} +``` + +Client code passes `useTranslation()`. Server code passes `await getTranslation(...)`. Avoid building translated sentences with template literals; translate the whole sentence or label with placeholders so translators can reorder words naturally. + ## `TyndaleProvider` props | Prop | Type | Default | Description | diff --git a/packages/tyndale-react/src/get-translation.ts b/packages/tyndale-react/src/get-translation.ts index a37f68e..692dd26 100644 --- a/packages/tyndale-react/src/get-translation.ts +++ b/packages/tyndale-react/src/get-translation.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { hash } from './hash.js'; import { interpolate } from './use-translation.js'; +import type { TranslationFn, TranslationVariables } from './types.js'; export interface GetTranslationOptions { /** Target locale code. */ @@ -12,11 +13,6 @@ export interface GetTranslationOptions { outputPath: string; } -type TranslationFn = ( - source: string, - vars?: Record, -) => string; - /** * Server-side async translation function. * Loads the locale file from disk and returns a t() function. @@ -45,7 +41,7 @@ export async function getTranslation( } } - return (source: string, vars?: Record): string => { + return (source: string, vars?: TranslationVariables): string => { const h = hash(source); const translated = translations[h] ?? source; return interpolate(translated, vars); diff --git a/packages/tyndale-react/src/index.ts b/packages/tyndale-react/src/index.ts index 319b53c..80d5ff9 100644 --- a/packages/tyndale-react/src/index.ts +++ b/packages/tyndale-react/src/index.ts @@ -12,6 +12,8 @@ export type { LocaleData, EntryType, Manifest, + TranslationVariables, + TranslationFn, } from './types.js'; export { useLocale } from './use-locale.js'; export { computeHash, hash } from './hash.js'; diff --git a/packages/tyndale-react/src/server.ts b/packages/tyndale-react/src/server.ts index a71f665..a3cece5 100644 --- a/packages/tyndale-react/src/server.ts +++ b/packages/tyndale-react/src/server.ts @@ -1 +1,2 @@ export { getTranslation, type GetTranslationOptions } from './get-translation.js'; +export type { TranslationFn, TranslationVariables } from './types.js'; diff --git a/packages/tyndale-react/src/types.ts b/packages/tyndale-react/src/types.ts index 2abb772..dfce4d0 100644 --- a/packages/tyndale-react/src/types.ts +++ b/packages/tyndale-react/src/types.ts @@ -54,6 +54,20 @@ export interface TyndaleConfig { */ export type LocaleData = Record; +/** + * Values available to `{name}` placeholders in translated plain strings. + */ +export type TranslationVariables = Record; + +/** + * Shared plain-string translator returned by `useTranslation()` on the client + * and `getTranslation()` on the server. + */ +export type TranslationFn = ( + source: string, + vars?: TranslationVariables, +) => string; + /** * Type of an extracted translation entry. * - "jsx": from `` component diff --git a/packages/tyndale-react/src/use-translation.ts b/packages/tyndale-react/src/use-translation.ts index 9016ce7..0dfe706 100644 --- a/packages/tyndale-react/src/use-translation.ts +++ b/packages/tyndale-react/src/use-translation.ts @@ -3,6 +3,7 @@ import { useContext, useCallback } from 'react'; import { TyndaleContext } from './context.js'; import { hash } from './hash.js'; +import type { TranslationFn, TranslationVariables } from './types.js'; /** * Interpolates {name} placeholders in a string with provided values. @@ -10,7 +11,7 @@ import { hash } from './hash.js'; */ export function interpolate( text: string, - vars?: Record, + vars?: TranslationVariables, ): string { if (!vars) return text; return text.replace(/\{(\w+)\}/g, (match, key) => { @@ -27,14 +28,11 @@ export function interpolate( * Returns t(source, vars?) that hashes source, looks up translation, * applies interpolation, and falls back to source. */ -export function useTranslation(): ( - source: string, - vars?: Record, -) => string { +export function useTranslation(): TranslationFn { const ctx = useContext(TyndaleContext); return useCallback( - (source: string, vars?: Record): string => { + (source: string, vars?: TranslationVariables): string => { const h = hash(source); const translated = ctx?.translations[h]; const result = translated ?? source; diff --git a/packages/tyndale-react/tests/__fixtures__/translation-fn-type.ts b/packages/tyndale-react/tests/__fixtures__/translation-fn-type.ts new file mode 100644 index 0000000..3538216 --- /dev/null +++ b/packages/tyndale-react/tests/__fixtures__/translation-fn-type.ts @@ -0,0 +1,20 @@ +import { useTranslation, type TranslationFn, type TranslationVariables } from '../../src/index'; +import { getTranslation, type TranslationFn as ServerTranslationFn } from '../../src/server'; + +const vars: TranslationVariables = { name: 'Ada', count: 2 }; + +declare const explicitTranslator: TranslationFn; + +const clientTranslator: TranslationFn = useTranslation(); + +async function loadServerTranslator(): Promise { + return getTranslation({ + locale: 'en', + defaultLocale: 'en', + outputPath: './public/_tyndale', + }); +} + +explicitTranslator('Hello, {name}', vars); +clientTranslator('Hello, {name}', vars); +void loadServerTranslator; diff --git a/packages/tyndale-react/tests/type-exports.test.ts b/packages/tyndale-react/tests/type-exports.test.ts new file mode 100644 index 0000000..6da647f --- /dev/null +++ b/packages/tyndale-react/tests/type-exports.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; + +const PKG_ROOT = join(import.meta.dir, '..'); +const TYPE_FIXTURE = join(import.meta.dir, '__fixtures__/translation-fn-type.ts'); + +describe('public type exports', () => { + test('TranslationFn is usable from root and server entry points', async () => { + const proc = Bun.spawn( + [ + 'bunx', + 'tsc', + '--ignoreConfig', + '--noEmit', + '--module', + 'ESNext', + '--moduleResolution', + 'bundler', + '--target', + 'ESNext', + '--jsx', + 'react-jsx', + '--strict', + '--skipLibCheck', + '--types', + 'node,bun', + TYPE_FIXTURE, + ], + { cwd: PKG_ROOT, stdout: 'pipe', stderr: 'pipe' }, + ); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + expect(exitCode, `${stdout}${stderr}`).toBe(0); + }); +}); diff --git a/packages/tyndale/src/extract/string-extractor.ts b/packages/tyndale/src/extract/string-extractor.ts index 9853f31..5c0631d 100644 --- a/packages/tyndale/src/extract/string-extractor.ts +++ b/packages/tyndale/src/extract/string-extractor.ts @@ -17,14 +17,19 @@ export interface StringExtractionResult { errors: ExtractionError[]; } -/** Names of tyndale-react hooks/functions whose return value is a `t()` function. */ -const T_FUNCTION_SOURCES = new Set(['useTranslation', 'getTranslation']); +/** Package entry points that expose extractable Tyndale runtime APIs. */ +const TYNDALE_REACT_SOURCE = 'tyndale-react'; +const TYNDALE_REACT_SERVER_SOURCE = 'tyndale-react/server'; + +/** Names of Tyndale functions whose return value is a `t()` function. */ +const CLIENT_T_FUNCTION_SOURCES = new Set(['useTranslation']); +const SERVER_T_FUNCTION_SOURCES = new Set(['getTranslation']); /** Names of marker functions whose first argument is an extractable string. */ const MARKER_FUNCTIONS = new Set(['msg', 'msgString']); export interface TBindings { - /** Local identifiers imported from `tyndale-react`. */ + /** Local identifiers imported from Tyndale runtime packages. */ tyndaleImports: Set; /** * Local identifiers that refer to an extractable callable — either a marker @@ -35,7 +40,7 @@ export interface TBindings { } /** - * Scan a module AST for tyndale-react imports and `t` bindings. Returns the + * Scan a module AST for Tyndale runtime imports and `t` bindings. Returns the * identifiers that the call-site extractor should treat as extractable. * * Separated from `extractStringCalls` so alternate front-ends (e.g. Astro, @@ -46,25 +51,34 @@ export function collectTBindings(ast: File): TBindings { const tyndaleImports = new Set(); const tBindings = new Set(); - // Pass 1: tyndale-react imports. + // Pass 1: Tyndale runtime imports. + const translationFunctionImports = new Set(); traverse(ast, { ImportDeclaration(path: any) { const source = path.node.source.value; - if (source !== 'tyndale-react') return; + if (source !== TYNDALE_REACT_SOURCE && source !== TYNDALE_REACT_SERVER_SOURCE) return; for (const specifier of path.node.specifiers) { - if (specifier.type === 'ImportSpecifier') { - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - const local = specifier.local.name; + if (specifier.type !== 'ImportSpecifier') continue; + + const imported = + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value; + const local = specifier.local.name; + if (source === TYNDALE_REACT_SOURCE && MARKER_FUNCTIONS.has(imported)) { tyndaleImports.add(local); + tBindings.add(local); + continue; + } - if (MARKER_FUNCTIONS.has(imported)) { - tBindings.add(local); - } + if ( + (source === TYNDALE_REACT_SOURCE && CLIENT_T_FUNCTION_SOURCES.has(imported)) || + (source === TYNDALE_REACT_SERVER_SOURCE && SERVER_T_FUNCTION_SOURCES.has(imported)) + ) { + tyndaleImports.add(local); + translationFunctionImports.add(local); } } }, @@ -94,7 +108,7 @@ export function collectTBindings(ast: File): TBindings { calleeName = init.argument.callee.name; } - if (calleeName && T_FUNCTION_SOURCES.has(calleeName) && tyndaleImports.has(calleeName)) { + if (calleeName && translationFunctionImports.has(calleeName)) { const localName = path.node.id.type === 'Identifier' ? path.node.id.name : null; if (localName) { tBindings.add(localName); diff --git a/packages/tyndale/tests/commands/__fixtures__/project/src/metadata.ts b/packages/tyndale/tests/commands/__fixtures__/project/src/metadata.ts new file mode 100644 index 0000000..d645038 --- /dev/null +++ b/packages/tyndale/tests/commands/__fixtures__/project/src/metadata.ts @@ -0,0 +1,14 @@ +import { getTranslation } from 'tyndale-react/server'; + +export async function generateMetadata() { + const t = await getTranslation({ + locale: 'fr', + defaultLocale: 'en', + outputPath: 'public/_tyndale', + }); + + return { + title: t('Server metadata title'), + description: t('Server metadata description'), + }; +} diff --git a/packages/tyndale/tests/commands/extract.test.ts b/packages/tyndale/tests/commands/extract.test.ts index 066e4cf..613c4ac 100644 --- a/packages/tyndale/tests/commands/extract.test.ts +++ b/packages/tyndale/tests/commands/extract.test.ts @@ -33,9 +33,9 @@ describe('runExtract (integration)', () => { expect(manifest.defaultLocale).toBe('en'); expect(manifest.locales).toEqual(['es', 'fr']); - // Count entries: 2 T components + 2 t() + 2 msg() + 2 dict = 8 + // Count entries: 2 T components + 2 client t() + 2 server t() + 2 msg() + 2 dict = 10 const entryCount = Object.keys(manifest.entries).length; - expect(entryCount).toBe(8); + expect(entryCount).toBe(10); // Verify entry types const entries = Object.values(manifest.entries) as any[]; @@ -44,7 +44,7 @@ describe('runExtract (integration)', () => { const dictEntries = entries.filter((e) => e.type === 'dictionary'); expect(jsxEntries).toHaveLength(2); - expect(stringEntries).toHaveLength(4); // 2 t() + 2 msg() + expect(stringEntries).toHaveLength(6); // 2 client t() + 2 server t() + 2 msg() expect(dictEntries).toHaveLength(2); // Verify dictionary metadata @@ -63,6 +63,8 @@ describe('runExtract (integration)', () => { expect(jsxValues).toContain('<0>Hello {user}'); expect(jsxValues).toContain('Enter your email'); expect(jsxValues).toContain('Email address'); + expect(jsxValues).toContain('Server metadata title'); + expect(jsxValues).toContain('Server metadata description'); expect(jsxValues).toContain('Home'); expect(jsxValues).toContain('About'); expect(jsxValues).toContain('Hello, welcome!'); diff --git a/packages/tyndale/tests/extract/string-extractor.test.ts b/packages/tyndale/tests/extract/string-extractor.test.ts index 720dec8..019b097 100644 --- a/packages/tyndale/tests/extract/string-extractor.test.ts +++ b/packages/tyndale/tests/extract/string-extractor.test.ts @@ -41,11 +41,11 @@ describe('extractStrings', () => { expect(result.entries[1].wireFormat).toBe('Email address'); }); - it('extracts getTranslation() destructured t() calls', () => { + it('extracts getTranslation() t() calls from the server subpath', () => { const code = ` - import { getTranslation } from 'tyndale-react'; + import { getTranslation } from 'tyndale-react/server'; async function Page() { - const t = await getTranslation(); + const t = await getTranslation({ locale: 'fr', outputPath: './public/_tyndale' }); return

{t('Page title')}

; } `; @@ -56,6 +56,35 @@ describe('extractStrings', () => { expect(result.entries[0].wireFormat).toBe('Page title'); }); + it('extracts aliased getTranslation() t() calls from the server subpath', () => { + const code = ` + import { getTranslation as getTyndaleTranslation } from 'tyndale-react/server'; + async function Page() { + const t = await getTyndaleTranslation({ locale: 'fr', outputPath: './public/_tyndale' }); + return

{t('Aliased page title')}

; + } + `; + const ast = parseSource(code, 'page.tsx'); + const result = extractStrings(ast, 'page.tsx'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].wireFormat).toBe('Aliased page title'); + }); + + it('does not extract getTranslation() t() calls from non-Tyndale imports', () => { + const code = ` + import { getTranslation } from './local-i18n'; + async function Page() { + const t = await getTranslation(); + return

{t('Not from Tyndale')}

; + } + `; + const ast = parseSource(code, 'page.tsx'); + const result = extractStrings(ast, 'page.tsx'); + + expect(result.entries).toHaveLength(0); + }); + it('extracts msg() calls', () => { const code = ` import { msg } from 'tyndale-react'; @@ -73,6 +102,44 @@ describe('extractStrings', () => { expect(result.entries[0].context).toMatch(/nav\.ts:msg@4/); }); + it('extracts pure helper strings marked with msgString()', () => { + const code = ` + import { msgString, type TranslationFn } from 'tyndale-react'; + + type Status = 'valid' | 'expired'; + + const STATUS_LABELS = { + valid: msgString('Compliant'), + expired: msgString('Expired'), + } satisfies Record; + + const STATUS_FILTER_LABEL = msgString('Status: {status}'); + + export function statusLabel(status: Status, t: TranslationFn): string { + return t(STATUS_LABELS[status]); + } + + export function activeFilterLabels(status: Status | undefined, t: TranslationFn): string[] { + const labels: string[] = []; + + if (status) { + labels.push(t(STATUS_FILTER_LABEL, { status: statusLabel(status, t) })); + } + + return labels; + } + `; + const ast = parseSource(code, 'helper.ts'); + const result = extractStrings(ast, 'helper.ts'); + + expect(result.entries.map((entry) => entry.wireFormat)).toEqual([ + 'Compliant', + 'Expired', + 'Status: {status}', + ]); + expect(result.errors).toHaveLength(0); + }); + it('reports error for template literal in t()', () => { const code = ` import { useTranslation } from 'tyndale-react';