尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
深入解析 Lingo.dev Compiler 转换管道:React 组件构建期自动化翻译注入的完整实现
深入解析 Lingo.dev Compiler 转换管道React 组件构建期自动化翻译注入的完整实现【免费下载链接】replexicaOpen-source localization engineering tools. Connects to Lingo.dev localization engineering platform for consistent, quality translations.项目地址: https://gitcode.com/GitHub_Trending/re/replexicaLingo.dev Compiler 是 replexica 仓库中新一代编译器的核心它在构建期通过 Babel AST 转换自动把 React 组件源码中的可翻译文本替换为翻译调用从而让开发者无需手写t()调用即可获得完整的 i18n 能力。本文将以其官方设计文档 TRANSFORMATION_PIPELINE.md 为骨架逐阶段拆解这条源码 JSX → AST 转换 → 元数据落库 → 代码生成的完整管道并结合仓库内的真实源码process-file.ts、utils.ts、metadata/manager.ts、unplugin.ts等验证每个环节的底层实现。读完本文你将掌握该编译器的文件过滤、组件识别、文本提取、哈希体系、LMDB 元数据管理、多打包器集成与运行时协作机制并具备直接阅读源码继续深入的能力。高层流程一条从 JSX 到翻译调用注入的自动化链路整个转换管道以构建期静态分析 运行时按需取词为设计基调其数据流如下Source JSX → Babel Parser → AST Transformation → Code Generation → Transformed JSX ↓ Metadata Extraction ↓ .lingo/metadata-{env}/ (LMDB database)翻译调用与可翻译文本的元数据哈希、上下文、位置信息在构建期被一次性提取并持久化到 LMDB 数据库而真正的翻译查找、locale 解析、缺失回退等逻辑则全部推迟到运行时由注入的 Hook 完成。这种提取与执行分离的架构带来了三个直接收益检测成本为零运行时开销、文本变更可追踪、打包产物天然带安全回退。从源码看这一流程的编排入口是 transform/index.ts 中的transformComponent()它先用babel/parser解析源码得到 AST再调用processFile()驱动 Babel visitor 完成组件检测与文本改写最后用babel/generator生成新的代码与 source map。若任意环节抛错函数会捕获异常并原样返回源码确保构建不因转换失败而中断。管道七大阶段1. 文件过滤只放行值得转换的文件位置插件入口unplugin 的transform.filter或 Next.js 的 loader 规则。目的决定哪些文件需要进入转换流程避免无谓的解析开销。文档给出的过滤规则在 unplugin.ts 中有精确的源码对应transform: { filter: { id: { include: [/\.[tj]sx$/], // 只处理 .tsx / .jsx exclude: /node_modules/, // 跳过 node_modules }, // useDirective 开启时仅放行含 use i18n 的文件 code: config.useDirective ? useI18nRegex : undefined, }, // ... }其中useI18nRegex定义于 use-i18n.ts即匹配use i18n字符串字面量的正则。之所以把指令检查下沉到filter.code源码注释明确指出这是比在 handler 内再校验更高效的做法——不匹配的文件根本不会进入转换回调。注意include正则中的\.[tj]sx$允许大小写变体兼容.TSX之类的写法。在 Next.js 集成next.ts中同样的过滤被表达为 Turbopack 规则pattern: *.{tsx,jsx} 可选的condition.content和 Webpack ruletest: /\.(tsx|jsx)$/iexclude: /node_modules/语义与 unplugin 完全一致。输出布尔值——是否转换该文件。2. 代码解析从源码到 Babel AST位置transformComponent()transform/index.ts。过程使用babel/parser以sourceType: module解析启用[jsx, typescript]两个插件得到可遍历、可修改的 AST。解析前还会做一次路径归一化path.relative(config.sourceRoot, filePath)后把分隔符统一为/得到跨平台一致的relativeFilePath——这是后面哈希稳定性的前提之一因为同一文本在不同文件路径下会生成不同哈希。输出Babel AST。3. 组件检测识别文件中的 React 组件位置processFile()中的 Babel visitorsprocess-file.ts。检测规则与文档完全一致并有源码支撑函数声明FunctionDeclaration、箭头函数ArrowFunctionExpression、函数表达式FunctionExpression三类节点统一交给processComponentFunction()处理判定是否为 React 组件的核心函数是 utils.ts 的isReactComponent()箭头函数直接检查函数体是否为JSXElement/JSXFragment其余函数则遍历所有ReturnStatement只要存在返回 JSX 的 return 即视为组件组件名的推断由inferComponentName()utils.ts完成优先取函数id其次取const MyComponent () ...的变量声明名export default function()则归名为default。组件类型文档明确——所有组件默认按Server Component处理带use client指令的为Client Component。值得注意的是源码中的判定依据是函数是否asyncisAsync的组件注入服务端 HookgetServerTranslations非 async 组件注入统一 HookuseTranslation详见第 5 阶段。从源码注释一个文件可以同时包含 async 与非 async 组件可以看出两类组件可以在同一文件中共存各自的 import 也会分别追加。处理过程中组件会被压入componentsStack栈栈顶即当前正在遍历的组件后续的文本提取、属性翻译、html lang注入都要依赖它定位上下文。组件处理完毕后从栈中弹出再统一注入翻译 Hook。组件类型Server Component默认与 Client Component带use client。4. 文本提取找出所有可翻译内容位置process-file.ts中的JSXElement/JSXFragmentvisitor。核心判定getTranslationScope()process-file.ts决定一个 JSX 节点能否翻译、按何种形态翻译。它先区分两种场景混合内容mixed contenthasMixedContent()process-file.ts用一个状态机检测子节点序列——文本节点、表达式、嵌套元素交错出现如Hello, {name}!、You have strong{count}/strong messages时整个片段被序列化为一条带 ICU 占位符的富文本翻译串纯文本仅含一个有效JSXText且其余只能是空白或 void 元素如br/时只改写该文本节点。什么会被转换 / 什么会被跳过文档示例均有源码对应divHello World/div // ✅ 转换生成内容条目content 类型 h1Welcome!/h1 // ✅ 转换 p /p // ❌ 跳过normalizeWhitespace 后为空串 span{variable}/span // ❌ 跳过无文本节点表达式不参与纯空白文本的过滤来自normalizeWhitespace()utils.ts——它把任意连续空白折叠为单空格并 trim空结果即放弃。表达式本身永远不会被翻译但在混合内容中会被提取为{varName}占位符并作为变量参数传入t()。文档之外的源码级细节可作为对文档的深化不可翻译元素NON_TRANSLATABLE_ELEMENTSprocess-file.ts默认跳过code、pre、script、style、kbd、samp、var的文本显式跳过shouldSkipTranslationForElement()process-file.ts同时识别 HTML 标准属性translateno与自定义属性data-lingo-skip属性翻译translateAttributes()process-file.ts把TRANSLATABLE_ATTRIBUTEStitle、aria-label、aria-description、alt、label、description、placeholder、content、subtitle中的字符串字面量也纳入翻译生成attribute类型的条目上下文记录attributeName混合内容序列化serializeJSXChildren()process-file.ts把嵌套元素改写为strong0.../strong0形式的标签占位并建立components映射供运行时以(chunks) strong{chunks}/strong渲染函数还原特殊文本转义escapeTextForICU()utils.ts按 ICU MessageFormat 规则对、{、}、、#进行转义防止占位符语法冲突。每个文本节点的处理流程提取文本内容先normalizeWhitespace折叠空白基于文本内容 上下文组件名、文件路径生成唯一哈希——createTranslationEntry()utils.ts内部调用generateTranslationHash()创建带上下文的元数据条目源文本、上下文组件名/文件路径/属性名、行/列位置、哈希以及可选的overrides针对特定 locale 的手工翻译覆盖定义见 types.ts。哈希算法的实现事实generateTranslationHash()hash.ts对sourceText 排序后的上下文键值对做MD5取前 12 个十六进制字符isValidHash用/^[a-f0-9]{12}$/校验。上下文参与哈希意味着同一句话出现在不同组件或不同文件中会得到不同哈希这正是翻译可跨代码变更持久化的基础源码注释也预留了改用 FNV-1a 加速的备选方案。输出若干翻译条目TranslationEntry类型包括content、attribute与metadataNext.js 元数据导出见 types.ts。5. 代码转换把文本替换为翻译调用位置process-file.ts的rewriteChildren()与injectTranslationHook()。文档中的标准示例// BEFORE export function Welcome() { return divHello World/div; } // AFTER (Server Component) import { getServerTranslations } from lingo.dev/compiler/react/server; export async function Welcome() { const { t } await getServerTranslations({ hashes: [a1b2c3d4e5f6], }); return div{t(a1b2c3d4e5f6, Hello World)}/div; }注入步骤源码验证文件级 import 追加addTranslationImports()process-file.ts在Program退出时把需要的 import 插入文件头部。服务端组件 importgetServerTranslations来自lingo.dev/compiler/react/server见 utils.ts非 async 组件 import 统一 HookuseTranslation来自lingo.dev/compiler/react见 utils.ts该模块通过条件导出在 Server 端解析到server.ts基于 Reactcacheuse、在 Client 端解析到index.ts基于 Context组件改造Server Component 经injectServerHook()utils.ts变为async并在函数体开头注入const { t } await getServerTranslations({ hashes: [...] })非 async 组件经injectUnifiedHook()utils.ts注入const t useTranslation([...hashes])。箭头函数的表达式体() jsx会被自动改写为块语句文本节点替换constructTranslationCall()utils.ts生成{t(hash, fallback, args?)}调用——前两个参数是哈希与回退文本第三个参数可选携带混合内容的变量shorthand 属性、表达式expression0: expr与组件渲染函数strong0: (chunks) strong{chunks}/strong供运行时完成 ICU 富文本插值隐式增强injectHtmlLangAttribute()process-file.ts会在组件内的html元素上注入lang{locale}若原本没有lang/language属性并把该组件标记为需要 locale——对应的 Hook 解构会多出locale字段const { t, locale } ...。这也是 Next.js 页面html lang能跟随当前 locale 自动变化的实现来源。6. 代码生成AST 还原为可运行代码位置transformComponent()transform/index.ts。过程使用babel/generator经babel-compat.ts导出把修改后的 AST 重新生成 JavaScriptsourceMaps: true保留调试能力并传入原始源码以尽量保持格式。最终返回{ code, map, newEntries, transformed }newEntries供调用方写入元数据transformed标记是否真的发生了改写translationEntries.length 0。输出转换后源码 source map。7. 元数据管理以 LMDB 为单一事实来源位置metadata/manager.ts。存储位置.lingo/metadata-dev/development或.lingo/metadata-build/production目录名由getMetadataPath()manager.ts依据environment计算。.lingo前缀来自配置项lingoDir默认lingo见 config-factory.ts 的默认值。为什么选 LMDB文档给出的理由均有源码佐证文档称其写速度约 100 万 ops/sec对比 SQLite 约 5 万、内存映射文件零拷贝读取、内置 LZ4 压缩、无 SQL 开销的简单 KV API。实现层面openDatabaseConnection()manager.ts以compression: trueLZ4与可配置的noSync打开数据库并通过动态import(lmdb)避免打包器改写其 CJS 产物。数据结构与操作// Key: a1b2c3d4e5f6 // Value对应 TranslationEntry: { type: content, sourceText: Hello World, context: { componentName: Welcome, filePath: components/Welcome.tsx }, location: { filePath: components/Welcome.tsx, line: 3, column: 10 }, hash: a1b2c3d4e5f6, overrides: { de: Hallo Welt } // 可选手工翻译覆盖 }三个核心操作manager.tsloadMetadata(dbPath)遍历db.getRange()读出全部条目为MetadataSchema哈希 → 条目saveMetadata(dbPath, entries)在db.transactionSync()内逐条putSync(entry.hash, entry)单事务原子写入cleanupExistingMetadata(dbPath)递归删除数据库目录ENOENT视为已清理在每次构建开始时调用避免旧条目污染新构建。连接管理上每次操作都走openDatabaseConnection → 执行 → closeDatabaseConnection的runWithDbConnection模式lmdb-js 对同一路径的open()在 C 层引用计数去重代价极低且每次 open 都会清理已终止 worker 的陈旧 reader。并发模型由 LMDB 内部保证多读单写。在 unplugin 的transformhandler 中saveMetadata的noSync参数在生产环境!isDev下为true即构建期用异步刷盘换取吞吐。Bundler 集成同一套逻辑跑通 Vite / Webpack / Rollup / esbuild / Next.js文档中的架构图在源码中有完整实现。unplugin 核心unplugin.ts通过createUnplugin一次性产出 Vite 与 Webpack 两套适配Rollup/esbuild 由 unplugin 生态自动派生Next.js 则因虚拟模块与 loader 的复杂性选择在 next.ts 的withLingo()中直接注入 Webpack rule 与 Turbopack rules而非复用 unplugin源码注释说明了原因loader 与虚拟模块在 unplugin 中会引发后续编译的诡异错误。┌─────────────────────────────────────────┐ │ unplugin Core │ │ (shared transformation logic) │ └─────────────────────────────────────────┘ │ ┌────────────┼────────────┬──────────┐ │ │ │ │ ┌──▼──┐ ┌───▼───┐ ┌───▼───┐ ┌───▼────┐ │Vite │ │Webpack│ │Rollup │ │esbuild │ └─────┘ └───────┘ └───────┘ └────────┘插件钩子文档列出的四个钩子在源码中的对应行为buildStart清理旧元数据cleanupExistingMetadatadev 模式下启动翻译服务器startServer()并发送构建开始追踪事件enforce: pre保证在 React 插件之前运行Vite 侧还会把lingo.dev/compiler加入optimizeDeps.excludetransformInclude / transform.filter按第 1 阶段的规则过滤文件transform调用transformComponent()transformed为 false 时返回null不修改模块否则把newEntries写入 LMDB 并返回{ code, map }任何异常都被捕获并记录为构建错误事件返回null保证构建不中断buildEnd生产环境下调用processBuildTranslations()把翻译预生成到public/translationsVite或distDirNext.js 的runAfterProductionCompile随后关闭翻译服务器Webpack 侧对应additionalPass/shutdown钩子。虚拟模块源码新增的亮点文档未展开unplugin.ts的virtualModules表注册了三个虚拟模块——lingo.dev/compiler/virtual/config生成配置模块、/virtual/locale/server与/virtual/locale/clientlocale 解析器。它们遵循先在sourceRoot/lingoDir下查找自定义文件如locale-resolver.server.ts找不到才用虚拟模块的优先级让用户可以用自定义 locale 解析逻辑覆盖默认实现。运行时集成注入后的代码如何工作转换只负责铺路真正的取词由运行时 Hook 完成Server Componentsconst { t, locale, translations } await getServerTranslations({ hashes: [hash1, hash2], // 构建期注入 locale: es, // 可选省略时由 locale resolver 自动探测 basePath: process.cwd(), // 可选翻译文件基准路径默认 cwd });工作流程文档 源码按哈希从.lingo/cache/{locale}.json取翻译 → 未指定 locale 时经配置的 locale resolver 自动探测 → 返回把哈希映射到译文的t()→ 缺失时回退到源码文本。Client Componentsconst t useTranslation();工作流程从LingoProviderContext 取 locale → 从 API 端点或预加载 bundle 加载翻译 → 按哈希查找 → 缺失回退源码文本。统一 HookuseTranslation通过条件导出在同一 API 下兼顾 Servercacheuse与 ClientContext两套实现这是新编译器对旧版getServerTranslations/useTranslation分离设计的收敛。翻译生成按需生成与三层策略当某个新 locale 被请求时走查缓存 → 缺失则生成 → 返回的链路Request locale → Check cache → Generate if missing → Return translations │ ├→ Pseudo: Transform text algorithmically ├→ LLM: Use AI model to translate └→ Lingo.dev: Use Lingo.dev Engine翻译服务器translation-server.ts承载按需生成能力文档称其运行在 60000–60099 端口——源码findAvailablePort()translation-server.ts正是从配置项dev.translationServerStartPort默认 60000开始向后探测最多 100 个端口与文档完全吻合。服务器提供三类端点源码handleRequest可证GET /health健康检查返回端口与配置哈希startOrGetUrl()借此复用已存在的翻译服务器实例配置哈希不一致则另起新实例POST /translations/:localebody:{ hashes: string[] }按哈希批量翻译供运行时按需取词GET /translations/:locale整本词典翻译Cache-Control: public, max-age3600适合构建期预生成。服务器每次请求前都会reloadMetadata()重读 LMDB确保构建期新增的条目被纳入翻译它还通过同端口的 WebSocket 广播server:busy/server:idle/batch:start/batch:complete事件配合lingo-dev-widget实现开发态实时反馈。翻译结果按 locale 缓存到.lingo/{locale}.json。关键设计原则构建期转换全部文本提取发生在构建期运行时零检测成本哈希驱动每个可翻译串获得稳定哈希文本 组件/文件上下文翻译可跨代码变更持久化——同一句子移动到别的文件或组件哈希随之改变从而获得独立翻译条目上下文感知每条翻译携带组件、文件、行/列位置以及属性名、父组件链等为翻译者提供语境回退安全转换产物内嵌原始文本作为t(hash, fallback)的第二参数翻译缺失不会破坏应用元数据驱动LMDB 数据库.lingo/metadata-{env}/是全部可翻译内容的单一事实来源供运行时、翻译服务器与构建期预生成共享通用兼容同一套转换逻辑通过 unplugin 覆盖 Vite、Webpack、Rollup、esbuild并为 Next.js 提供专用 loader 集成。完整转换流程示例文档原文 源码注解// 1. ORIGINAL SOURCE export function Greeting({ name }) { return h1Hello, {name}!/h1; } // 2. DETECTIONisReactComponent 判定返回 JSX组件名 infer 为 Greeting // - Found component: Greeting // - Found text: Hello, {name}混合内容序列化为 ICU 串 // - Generated hash: a1b2c3d4e5f6MD5 前 12 位基于文本 上下文 // 3. METADATA CREATED写入 .lingo/metadata-dev/ 或 metadata-build/ { a1b2c3d4e5f6: { type: content, sourceText: Hello, {name}, context: { componentName: Greeting, filePath: src/Greeting.tsx }, location: { filePath: src/Greeting.tsx, line: 2, column: 9 } } } // 4. TRANSFORMED OUTPUTinjectServerHook rewriteChildren 的产物 import { getServerTranslations } from lingo.dev/compiler/react/server; export async function Greeting({ name }) { const { t } await getServerTranslations({ hashes: [a1b2c3d4e5f6], }); return h1{t(a1b2c3d4e5f6, Hello, {name}, { name })}/h1; } // 5. RUNTIME EXECUTION // - t() 按哈希查找翻译西班牙语下返回 Hola, {name} // - 缺失时回退 Hello, {name}应用不受影响注意第 4 步输出与文档略有的差异混合内容会生成带变量参数的三参调用源码constructTranslationCall行为比文档的纯文本示例更贴近真实产物。性能考量与错误处理性能设计文档要点 源码印证缓存元数据在每个文件转换期间只加载一次LMDB 连接按操作复用选择性转换仅改写 JSX 文本节点与可翻译属性表达式原样保留isVoidElement等跳过逻辑避免无效改写并行处理各打包器可并发转换文件LMDB 多读单写模型保障写入安全增量只有变更文件被重新转换由打包器自身的文件监听负责Source Mapsbabel/generator开启sourceMaps: true保留调试体验过滤前置transform.filter在进入 handler 前拦截非目标文件useDirective模式下连解析都省去。错误处理分级文档四类错误均有源码落地解析错误transformComponent()catch 后记录logger.error并原样返回源码transformed: false转换错误unplugin handler 捕获后置hasTransformErrors true、上报BUILD_ERROR追踪事件返回null让打包器继续元数据错误文件不存在时按空元数据处理cleanupExistingMetadata对ENOENT静默放行翻译错误运行时t()缺失即回退源码文本翻译服务器对单条翻译失败在响应中返回errors数组并记录日志批量任务仍继续。进一步阅读管道设计文档原文TRANSFORMATION_PIPELINE.md转换入口与代码生成transform/index.ts、transform/babel-compat.ts核心 visitor 实现transform/process-file.ts、transform/utils.ts元数据LMDB管理metadata/manager.ts哈希算法与合法性校验utils/hash.tsunplugin 与 Next.js 集成plugin/unplugin.ts、plugin/next.ts翻译服务器translation-server/translation-server.ts条目类型定义与配置默认值types.ts、utils/config-factory.ts转换行为测试与快照transform/transform.test.ts、transform/snapshots/transform.test.ts.snap【免费下载链接】replexicaOpen-source localization engineering tools. Connects to Lingo.dev localization engineering platform for consistent, quality translations.项目地址: https://gitcode.com/GitHub_Trending/re/replexica创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED

相关推荐

PLC工程师从培训班到产线的四大知识断层

PLC工程师从培训班到产线的四大知识断层

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/9/17 14:42:38
跨境电商怎么做才能把十几个店铺管好?多店铺账号分组、批量运营高效管理办法

跨境电商怎么做才能把十几个店铺管好?多店铺账号分组、批量运营高效管理办法

跨境电商怎么做才能把十几个店铺管好,瓶颈通常不在人手,而在结构。店少的时候靠记性和个人习惯还能撑住,一旦超过十家,没有分组、没有批量手段、没有权限划分,人再多也会乱。 这篇文章按"先解决哪三个瓶颈、分组怎…

📅 2026/9/17 14:42:38
物联网云平台海量数据存储架构:从时序库选型到数据接入全链路

物联网云平台海量数据存储架构:从时序库选型到数据接入全链路

简介:《物联网云平台项目建设方案》是一份面向解决方案架构师、项目规划人员及物联网技术决策者的完整技术方案文档,围绕“云平台海量大数据存储”构建统一数据支撑平台,可应用在智慧城市、智能家居、工业自动化等物联网高频场景。资源为单个…

📅 2026/9/17 14:37:38
MORE NEWS

更多资讯

📰

AUTOSAR FEE换页机制详解:Flash存储可靠性的核心设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Home Assistant Insteon 集成 X10 All lights on 动作使用指南

Home Assistant Insteon 集成 X10 All lights on 动作使用指南 【免费下载链接】home-assistant.io :blue_book: Home Assistant User documentation 项目地址: https://gitcode.com/GitHub_Trending/ho/home-assistant.io 导读 本文讲解 Home Assistant Insteon 集成中…

📰

RTranslator 模型下载完整指南:10 个 .onnx 模型部署路径一次讲清

RTranslator 模型下载完整指南:10 个 .onnx 模型部署路径一次讲清 【免费下载链接】RTranslator Open source real-time translation app for Android that runs locally 项目地址: https://gitcode.com/GitHub_Trending/rt/RTranslator RTranslator 是一款开…

📰

pstack Feature Verification Map 实战指南:为 Agent 冷启动编写可执行的用户视角验证地图

pstack Feature Verification Map 实战指南:为 Agent 冷启动编写可执行的用户视角验证地图 【免费下载链接】plugins Cursor plugin specification and official plugins 项目地址: https://gitcode.com/GitHub_Trending/plugins125/plugins 导读 本文以 ps…

📰

小爱音箱免费听歌完整指南:xiaomusic 十分钟部署,说出歌名就能播

小爱音箱免费听歌完整指南:xiaomusic 十分钟部署,说出歌名就能播 【免费下载链接】xiaomusic 使用小爱音箱播放音乐,音乐使用 yt-dlp 下载。 项目地址: https://gitcode.com/GitHub_Trending/xia/xiaomusic 晚饭前你在厨房刷碗&#x…

📰

SCCM 2012运维实战:从架构到补丁管理的排查与沉淀

简介:面向企业IT运维人员的SCCM 2012运维文档,以微软System Center Configuration Manager 2012为核心,系统梳理了SCCM 2012的部署与功能验证完整路径。文档从环境介绍、部署需求(含系统要求、SQL Server版本要求)、SC…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬