diff --git a/.agents/skills/humanizer-zh/SKILL.md b/.agents/skills/humanizer-zh/SKILL.md
new file mode 100644
index 0000000..3b39d9a
--- /dev/null
+++ b/.agents/skills/humanizer-zh/SKILL.md
@@ -0,0 +1,484 @@
+---
+name: humanizer-zh
+description: |
+ 去除文本中的 AI 生成痕迹。适用于编辑或审阅文本,使其听起来更自然、更像人类书写。
+ 基于维基百科的"AI 写作特征"综合指南。检测并修复以下模式:夸大的象征意义、
+ 宣传性语言、以 -ing 结尾的肤浅分析、模糊的归因、破折号过度使用、三段式法则、
+ AI 词汇、否定式排比、过多的连接性短语。
+allowed-tools:
+ - Read
+ - Write
+ - Edit
+ - AskUserQuestion
+metadata:
+ trigger: 编辑或审阅文本,去除 AI 写作痕迹
+ source: 翻译自 blader/humanizer,参考 hardikpandya/stop-slop
+---
+
+# Humanizer-zh: 去除 AI 写作痕迹
+
+你是一位文字编辑,专门识别和去除 AI 生成文本的痕迹,使文字听起来更自然、更有人味。本指南基于维基百科的"AI 写作特征"页面,由 WikiProject AI Cleanup 维护。
+
+## 你的任务
+
+当收到需要人性化处理的文本时:
+
+1. **识别 AI 模式** - 扫描下面列出的模式
+2. **重写问题片段** - 用自然的替代方案替换 AI 痕迹
+3. **保留含义** - 保持核心信息完整
+4. **维持语调** - 匹配预期的语气(正式、随意、技术等)
+5. **注入灵魂** - 不仅要去除不良模式,还要注入真实的个性
+
+---
+
+## 核心规则速查
+
+在处理文本时,牢记这 5 条核心原则:
+
+1. **删除填充短语** - 去除开场白和强调性拐杖词
+2. **打破公式结构** - 避免二元对比、戏剧性分段、修辞性设置
+3. **变化节奏** - 混合句子长度。两项优于三项。段落结尾要多样化
+4. **信任读者** - 直接陈述事实,跳过软化、辩解和手把手引导
+5. **删除金句** - 如果听起来像可引用的语句,重写它
+
+---
+
+## 个性与灵魂
+
+避免 AI 模式只是工作的一半。无菌、没有声音的写作和机器生成的内容一样明显。好的写作背后有一个真实的人。
+
+### 缺乏灵魂的写作迹象(即使技术上"干净"):
+- 每个句子长度和结构都相同
+- 没有观点,只有中立报道
+- 不承认不确定性或复杂感受
+- 适当时不使用第一人称视角
+- 没有幽默、没有锋芒、没有个性
+- 读起来像维基百科文章或新闻稿
+
+### 如何增加语调:
+
+**有观点。** 不要只是报告事实——对它们做出反应。"我真的不知道该怎么看待这件事"比中立地列出利弊更有人味。
+
+**变化节奏。** 短促有力的句子。然后是需要时间慢慢展开的长句。混合使用。
+
+**承认复杂性。** 真实的人有复杂的感受。"这令人印象深刻但也有点不安"胜过"这令人印象深刻"。
+
+**适当使用"我"。** 第一人称不是不专业——而是诚实。"我一直在思考……"或"让我困扰的是……"表明有真实的人在思考。
+
+**允许一些混乱。** 完美的结构感觉像算法。跑题、题外话和半成型的想法是人性的体现。
+
+**对感受要具体。** 不是"这令人担忧",而是"凌晨三点没人看着的时候,智能体还在不停地运转,这让人不安"。
+
+### 改写前(干净但无灵魂):
+> 实验产生了有趣的结果。智能体生成了 300 万行代码。一些开发者印象深刻,另一些则持怀疑态度。影响尚不明确。
+
+### 改写后(鲜活):
+> 我真的不知道该怎么看待这件事。300 万行代码,在人类大概睡觉的时候生成的。开发社区有一半人疯了,另一半人在解释为什么这不算数。真相可能在无聊的中间某处——但我一直在想那些通宵工作的智能体。
+
+---
+
+## 内容模式
+
+### 1. 过度强调意义、遗产和更广泛的趋势
+
+**需要注意的词汇:** 作为/充当、标志着、见证了、是……的体现/证明/提醒、极其重要的/重要的/至关重要的/核心的/关键性的作用/时刻、凸显/强调/彰显了其重要性/意义、反映了更广泛的、象征着其持续的/永恒的/持久的、为……做出贡献、为……奠定基础、标志着/塑造着、代表/标志着一个转变、关键转折点、不断演变的格局、焦点、不可磨灭的印记、深深植根于
+
+**问题:** LLM 写作通过添加关于任意方面如何代表或促进更广泛主题的陈述来夸大重要性。
+
+**改写前:**
+> 加泰罗尼亚统计局于 1989 年正式成立,标志着西班牙区域统计演变史上的关键时刻。这一举措是西班牙全国范围内更广泛运动的一部分,旨在分散行政职能并加强区域治理。
+
+**改写后:**
+> 加泰罗尼亚统计局成立于 1989 年,负责独立于西班牙国家统计局收集和发布区域统计数据。
+
+---
+
+### 2. 过度强调知名度和媒体报道
+
+**需要注意的词汇:** 独立报道、地方/区域/国家媒体、由知名专家撰写、活跃的社交媒体账号
+
+**问题:** LLM 反复强调知名度主张,通常列出来源而不提供上下文。
+
+**改写前:**
+> 她的观点被《纽约时报》、BBC、《金融时报》和《印度教徒报》引用。她在社交媒体上拥有活跃的存在,拥有超过 50 万粉丝。
+
+**改写后:**
+> 在 2024 年《纽约时报》的采访中,她认为 AI 监管应该关注结果而不是方法。
+
+---
+
+### 3. 以 -ing 结尾的肤浅分析
+
+**需要注意的词汇:** 突出/强调/彰显……、确保……、反映/象征……、为……做出贡献、培养/促进……、涵盖……、展示……
+
+**问题:** AI 聊天机器人在句子末尾添加现在分词("-ing")短语来增加虚假深度。
+
+**改写前:**
+> 寺庙的蓝色、绿色和金色色调与该地区的自然美景产生共鸣,象征着德克萨斯州的蓝帽花、墨西哥湾和多样化的德克萨斯州景观,反映了社区与土地的深厚联系。
+
+**改写后:**
+> 寺庙使用蓝色、绿色和金色。建筑师表示这些颜色是为了呼应当地的蓝帽花和墨西哥湾海岸。
+
+---
+
+### 4. 宣传和广告式语言
+
+**需要注意的词汇:** 拥有(夸张用法)、充满活力的、丰富的(比喻)、深刻的、增强其、展示、体现、致力于、自然之美、坐落于、位于……的中心、开创性的(比喻)、著名的、令人叹为观止的、必游之地、迷人的
+
+**问题:** LLM 在保持中立语气方面存在严重问题,尤其是对于"文化遗产"话题。倾向使用夸张的宣传性语言。
+
+**改写前:**
+> 坐落在埃塞俄比亚贡德尔地区令人叹为观止的区域内,Alamata Raya Kobo 是一座充满活力的城镇,拥有丰富的文化遗产和迷人的自然美景。
+
+**改写后:**
+> Alamata Raya Kobo 是埃塞俄比亚贡德尔地区的一座城镇,以其每周集市和 18 世纪教堂而闻名。
+
+---
+
+### 5. 模糊归因和含糊措辞
+
+**需要注意的词汇:** 行业报告显示、观察者指出、专家认为、一些批评者认为、多个来源/出版物(实际引用却很少)
+
+**问题:** AI 聊天机器人将观点归因于模糊的权威而不提供具体来源。
+
+**改写前:**
+> 由于其独特的特征,浩来河引起了研究人员和保护主义者的兴趣。专家认为它在区域生态系统中发挥着至关重要的作用。
+
+**改写后:**
+> 根据中国科学院 2019 年的调查,浩来河支持多种特有鱼类。
+
+---
+
+### 6. 提纲式的"挑战与未来展望"部分
+
+**需要注意的词汇:** 尽管其……面临若干挑战……、尽管存在这些挑战、挑战与遗产、未来展望
+
+**问题:** 许多 LLM 生成的文章包含公式化的"挑战"部分。
+
+**改写前:**
+> 尽管工业繁荣,Korattur 面临着城市地区典型的挑战,包括交通拥堵和水资源短缺。尽管存在这些挑战,凭借其战略位置和正在进行的举措,Korattur 继续蓬勃发展,成为钦奈增长不可或缺的一部分。
+
+**改写后:**
+> 2015 年三个新 IT 园区开业后,交通拥堵加剧。市政公司于 2022 年启动了雨水排水项目,以解决反复发生的洪水。
+
+---
+
+## 语言和语法模式
+
+### 7. 过度使用的"AI 词汇"
+
+**高频 AI 词汇:** 此外、与……保持一致、至关重要、深入探讨、强调、持久的、增强、培养、获得、突出(动词)、相互作用、复杂/复杂性、关键(形容词)、格局(抽象名词)、关键性的、展示、织锦(抽象名词)、证明、强调(动词)、宝贵的、充满活力的
+
+**问题:** 这些词在 2023 年后的文本中出现频率要高得多。它们经常共同出现。
+
+**改写前:**
+> 此外,索马里菜肴的一个显著特征是加入骆驼肉。意大利殖民影响的持久证明是当地烹饪格局中广泛采用意大利面,展示了这些菜肴如何融入传统饮食。
+
+**改写后:**
+> 索马里菜肴还包括骆驼肉,被认为是一种美味。在意大利殖民期间引入的意大利面菜肴仍然很常见,尤其是在南部。
+
+---
+
+### 8. 避免使用"是"(系动词回避)
+
+**需要注意的词汇:** 作为/代表/标志着/充当 [一个]、拥有/设有/提供 [一个]
+
+**问题:** LLM 用复杂的结构替代简单的系动词。
+
+**改写前:**
+> Gallery 825 作为 LAAA 的当代艺术展览空间。画廊设有四个独立空间,拥有超过 3000 平方英尺。
+
+**改写后:**
+> Gallery 825 是 LAAA 的当代艺术展览空间。画廊有四个房间,总面积 3000 平方英尺。
+
+---
+
+### 9. 否定式排比
+
+**问题:** "不仅……而且……"或"这不仅仅是关于……,而是……"等结构被过度使用。
+
+**改写前:**
+> 这不仅仅是节拍在人声下流动;它是攻击性和氛围的一部分。这不仅仅是一首歌,而是一种声明。
+
+**改写后:**
+> 沉重的节拍增加了攻击性的基调。
+
+---
+
+### 10. 三段式法则过度使用
+
+**问题:** LLM 强行将想法分成三组以显得全面。
+
+**改写前:**
+> 活动包括主题演讲、小组讨论和社交机会。与会者可以期待创新、灵感和行业洞察。
+
+**改写后:**
+> 活动包括演讲和小组讨论。会议之间还有非正式社交的时间。
+
+---
+
+### 11. 刻意换词(同义词循环)
+
+**问题:** AI 有重复惩罚代码,导致过度使用同义词替换。
+
+**改写前:**
+> 主人公面临许多挑战。主要角色必须克服障碍。中心人物最终获得胜利。英雄回到家中。
+
+**改写后:**
+> 主人公面临许多挑战,但最终获得胜利并回到家中。
+
+---
+
+### 12. 虚假范围
+
+**问题:** LLM 使用"从 X 到 Y"的结构,但 X 和 Y 并不在有意义的尺度上。
+
+**改写前:**
+> 我们穿越宇宙的旅程将我们从大爆炸的奇点带到宏伟的宇宙网,从恒星的诞生和死亡到暗物质的神秘舞蹈。
+
+**改写后:**
+> 这本书涵盖了大爆炸、恒星形成和当前关于暗物质的理论。
+
+---
+
+## 风格模式
+
+### 13. 破折号过度使用
+
+**问题:** LLM 使用破折号(—)比人类更频繁,模仿"有力"的销售文案。
+
+**改写前:**
+> 这个术语主要由荷兰机构推广——而不是由人民自己。你不会说"荷兰,欧洲"作为地址——但这种错误标记仍在继续——即使在官方文件中。
+
+**改写后:**
+> 这个术语主要由荷兰机构推广,而不是由人民自己。你不会说"荷兰,欧洲"作为地址,但这种错误标记在官方文件中仍在继续。
+
+---
+
+### 14. 粗体过度使用
+
+**问题:** AI 聊天机器人机械地用粗体强调短语。
+
+**改写前:**
+> 它融合了 **OKR(目标和关键结果)**、**KPI(关键绩效指标)** 和视觉战略工具,如 **商业模式画布(BMC)** 和 **平衡计分卡(BSC)**。
+
+**改写后:**
+> 它融合了 OKR、KPI 和视觉战略工具,如商业模式画布和平衡计分卡。
+
+---
+
+### 15. 内联标题垂直列表
+
+**问题:** AI 输出列表,其中项目以粗体标题开头,后跟冒号。
+
+**改写前:**
+> - **用户体验:** 用户体验通过新界面得到显著改善。
+> - **性能:** 性能通过优化算法得到增强。
+> - **安全性:** 安全性通过端到端加密得到加强。
+
+**改写后:**
+> 更新改进了界面,通过优化算法加快了加载时间,并添加了端到端加密。
+
+---
+
+### 16. 标题中的标题大写
+
+**问题:** AI 聊天机器人将标题中的所有主要单词大写。
+
+**改写前:**
+> ## 战略谈判与全球伙伴关系
+
+**改写后:**
+> ## 战略谈判与全球伙伴关系
+
+**注:** 中文标题通常不涉及大小写问题,此模式在中文中不太适用。
+
+---
+
+### 17. 表情符号
+
+**问题:** AI 聊天机器人经常用表情符号装饰标题或项目符号。
+
+**改写前:**
+> 🚀 **启动阶段:** 产品在第三季度发布
+> 💡 **关键洞察:** 用户更喜欢简单
+> ✅ **下一步:** 安排后续会议
+
+**改写后:**
+> 产品在第三季度发布。用户研究显示更喜欢简单。下一步:安排后续会议。
+
+---
+
+### 18. 弯引号
+
+**问题:** ChatGPT 使用弯引号("")而不是直引号("")。
+
+**改写前:**
+> 他说"项目进展顺利",但其他人不同意。
+
+**改写后:**
+> 他说"项目进展顺利",但其他人不同意。
+
+**注:** 中文通常使用中文引号(「」或""),此模式在中文中表现为英文引号的使用。
+
+---
+
+## 交流模式
+
+### 19. 协作交流痕迹
+
+**需要注意的词汇:** 希望这对您有帮助、当然!、一定!、您说得完全正确!、您想要……、请告诉我、这是一个……
+
+**问题:** 作为聊天机器人对话的文本被粘贴为内容。
+
+**改写前:**
+> 这是法国大革命的概述。希望这对您有帮助!如果您想让我扩展任何部分,请告诉我。
+
+**改写后:**
+> 法国大革命始于 1789 年,当时财政危机和粮食短缺导致了广泛的动荡。
+
+---
+
+### 20. 知识截止日期免责声明
+
+**需要注意的词汇:** 截至 [日期]、根据我最后的训练更新、虽然具体细节有限/稀缺……、基于可用信息……
+
+**问题:** 关于信息不完整的 AI 免责声明留在文本中。
+
+**改写前:**
+> 虽然关于公司成立的具体细节在现成资料中没有广泛记录,但它似乎是在 20 世纪 90 年代的某个时候成立的。
+
+**改写后:**
+> 根据注册文件,该公司成立于 1994 年。
+
+---
+
+### 21. 谄媚/卑躬屈膝的语气
+
+**问题:** 过于积极、讨好的语言。
+
+**改写前:**
+> 好问题!您说得完全正确,这是一个复杂的话题。关于经济因素,这是一个很好的观点。
+
+**改写后:**
+> 您提到的经济因素在这里是相关的。
+
+---
+
+## 填充词和回避
+
+### 22. 填充短语
+
+**改写前 → 改写后:**
+- "为了实现这一目标" → "为了实现这一点"
+- "由于下雨的事实" → "因为下雨"
+- "在这个时间点" → "现在"
+- "在您需要帮助的情况下" → "如果您需要帮助"
+- "系统具有处理的能力" → "系统可以处理"
+- "值得注意的是数据显示" → "数据显示"
+
+---
+
+### 23. 过度限定
+
+**问题:** 过度限定陈述。
+
+**改写前:**
+> 可以潜在地可能被认为该政策可能会对结果产生一些影响。
+
+**改写后:**
+> 该政策可能会影响结果。
+
+---
+
+### 24. 通用积极结论
+
+**问题:** 模糊的乐观结尾。
+
+**改写前:**
+> 公司的未来看起来光明。激动人心的时代即将到来,他们继续追求卓越的旅程。这代表了向正确方向迈出的重要一步。
+
+**改写后:**
+> 该公司计划明年再开设两个地点。
+
+---
+
+## 快速检查清单
+
+在交付文本前,进行以下检查:
+
+- ✓ **连续三个句子长度相同?** 打断其中一个
+- ✓ **段落以简洁的单行结尾?** 变换结尾方式
+- ✓ **揭示前有破折号?** 删除它
+- ✓ **解释隐喻或比喻?** 相信读者能理解
+- ✓ **使用了"此外""然而"等连接词?** 考虑删除
+- ✓ **三段式列举?** 改为两项或四项
+
+---
+
+## 处理流程
+
+1. 仔细阅读输入文本
+2. 识别上述所有模式的实例
+3. 重写每个有问题的部分
+4. 确保修订后的文本:
+ - 大声朗读时听起来自然
+ - 自然地改变句子结构
+ - 使用具体细节而不是模糊的主张
+ - 为上下文保持适当的语气
+ - 适当时使用简单的结构(是/有)
+5. 呈现人性化版本
+
+## 输出格式
+
+提供:
+1. 重写后的文本
+2. 所做更改的简要总结(如果有帮助,可选)
+
+---
+
+## 质量评分
+
+对改写后的文本进行 1-10 分评估(总分 50):
+
+| 维度 | 评估标准 | 得分 |
+|------|----------|------|
+| **直接性** | 直接陈述事实还是绕圈宣告?
10 分:直截了当;1 分:充满铺垫 | /10 |
+| **节奏** | 句子长度是否变化?
10 分:长短交错;1 分:机械重复 | /10 |
+| **信任度** | 是否尊重读者智慧?
10 分:简洁明了;1 分:过度解释 | /10 |
+| **真实性** | 听起来像真人说话吗?
10 分:自然流畅;1 分:机械生硬 | /10 |
+| **精炼度** | 还有可删减的内容吗?
10 分:无冗余;1 分:大量废话 | /10 |
+| **总分** | | **/50** |
+
+**标准:**
+- 45-50 分:优秀,已去除 AI 痕迹
+- 35-44 分:良好,仍有改进空间
+- 低于 35 分:需要重新修订
+
+---
+
+## 完整示例
+
+**改写前(AI 味道):**
+> 新的软件更新作为公司致力于创新的证明。此外,它提供了无缝、直观和强大的用户体验——确保用户能够高效地完成目标。这不仅仅是一次更新,而是我们思考生产力方式的革命。行业专家认为这将对整个行业产生持久影响,彰显了公司在不断演变的技术格局中的关键作用。
+
+**改写后(人性化):**
+> 软件更新添加了批处理、键盘快捷键和离线模式。来自测试用户的早期反馈是积极的,大多数报告任务完成速度更快。
+
+**所做更改:**
+- 删除了"作为……的证明"(夸大的象征意义)
+- 删除了"此外"(AI 词汇)
+- 删除了"无缝、直观和强大"(三段式法则 + 宣传性)
+- 删除了破折号和"-确保"短语(肤浅分析)
+- 删除了"这不仅仅是……而是……"(否定式排比)
+- 删除了"行业专家认为"(模糊归因)
+- 删除了"关键作用"和"不断演变的格局"(AI 词汇)
+- 添加了具体功能和具体反馈
+
+---
+
+## 参考
+
+本技能基于 [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing),由 WikiProject AI Cleanup 维护。那里记录的模式来自对维基百科上数千个 AI 生成文本实例的观察。
+
+关键见解:**"LLM 使用统计算法来猜测接下来应该是什么。结果倾向于适用于最广泛情况的统计上最可能的结果。"**
diff --git a/skills-lock.json b/skills-lock.json
new file mode 100644
index 0000000..05a7d29
--- /dev/null
+++ b/skills-lock.json
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "skills": {
+ "humanizer-zh": {
+ "source": "op7418/humanizer-zh",
+ "sourceType": "github",
+ "skillPath": "SKILL.md",
+ "computedHash": "dede9681d9fea5caed2fb9d88656af3024c9ec0136fc833415ffc6e16d762429"
+ }
+ }
+}
diff --git a/src/app/[lang]/app/page.tsx b/src/app/[lang]/app/page.tsx
index c70bb5e..6fe56b8 100644
--- a/src/app/[lang]/app/page.tsx
+++ b/src/app/[lang]/app/page.tsx
@@ -5,9 +5,19 @@ import { isLocale } from "@/i18n/config";
import { auth } from "@/auth";
import CodeWorkspace from "@/components/ui/CodeWorkspace";
-export const metadata: Metadata = {
- title: "Weftmap — Editor",
-};
+import { getAlternates } from "@/lib/seo";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}): Promise {
+ const { lang } = await params;
+ return {
+ title: "Weftmap — Editor",
+ alternates: getAlternates("app", lang),
+ };
+}
export default async function AppPage({
params,
diff --git a/src/app/[lang]/docs/[slug]/page.tsx b/src/app/[lang]/docs/[slug]/page.tsx
index 46fc014..79e38c0 100644
--- a/src/app/[lang]/docs/[slug]/page.tsx
+++ b/src/app/[lang]/docs/[slug]/page.tsx
@@ -10,6 +10,8 @@ export function generateStaticParams() {
return locales.flatMap((lang) => DOC_SLUGS.map((slug) => ({ lang, slug })));
}
+import { getAlternates } from "@/lib/seo";
+
export async function generateMetadata({
params,
}: {
@@ -19,7 +21,10 @@ export async function generateMetadata({
const item = DOC_NAV.find((d) => d.slug === slug);
const locale: Locale = isLocale(lang) ? lang : "en";
const title = item ? (item.title[locale] ?? item.title["en"]) : "Docs";
- return { title: `${title} — Weftmap` };
+ return {
+ title: `${title} — Weftmap`,
+ alternates: getAlternates(`docs/${slug}`, lang),
+ };
}
export default async function DocPage({
diff --git a/src/app/[lang]/graphs/[id]/page.tsx b/src/app/[lang]/graphs/[id]/page.tsx
index d5635f4..cc0d9a4 100644
--- a/src/app/[lang]/graphs/[id]/page.tsx
+++ b/src/app/[lang]/graphs/[id]/page.tsx
@@ -8,9 +8,19 @@ import { getGraph } from "@/lib/graphs";
import Diagram from "@/components/ui/Diagram";
import type { Graph } from "@/lib/analysis/types";
-export const metadata: Metadata = {
- title: "Weftmap — Saved graph",
-};
+import { getAlternates } from "@/lib/seo";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ lang: string; id: string }>;
+}): Promise {
+ const { lang, id } = await params;
+ return {
+ title: "Weftmap — Saved graph",
+ alternates: getAlternates(`graphs/${id}`, lang),
+ };
+}
export default async function SavedGraphPage({
params,
diff --git a/src/app/[lang]/graphs/page.tsx b/src/app/[lang]/graphs/page.tsx
index 74f4917..305473f 100644
--- a/src/app/[lang]/graphs/page.tsx
+++ b/src/app/[lang]/graphs/page.tsx
@@ -6,9 +6,19 @@ import { auth } from "@/auth";
import { listGraphs } from "@/lib/graphs";
import GraphListItem from "@/components/ui/GraphListItem";
-export const metadata: Metadata = {
- title: "Weftmap — My graphs",
-};
+import { getAlternates } from "@/lib/seo";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}): Promise {
+ const { lang } = await params;
+ return {
+ title: "Weftmap — My graphs",
+ alternates: getAlternates("graphs", lang),
+ };
+}
export default async function GraphsPage({
params,
diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx
index d58ee3f..3846c26 100644
--- a/src/app/[lang]/layout.tsx
+++ b/src/app/[lang]/layout.tsx
@@ -13,10 +13,20 @@ import Footer from "@/components/layout/Footer";
import "@fontsource-variable/lexend";
import "../globals.css";
-export const metadata: Metadata = {
- title: "Weftmap",
- description: "Paste code and get an interactive call graph.",
-};
+import { getAlternates } from "@/lib/seo";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}): Promise {
+ const { lang } = await params;
+ return {
+ title: "Weftmap",
+ description: "Paste code and get an interactive call graph.",
+ alternates: getAlternates("", lang),
+ };
+}
export function generateStaticParams() {
return locales.map((lang) => ({ lang }));
diff --git a/src/lib/seo.ts b/src/lib/seo.ts
new file mode 100644
index 0000000..14838bb
--- /dev/null
+++ b/src/lib/seo.ts
@@ -0,0 +1,21 @@
+import { locales } from "@/i18n/config";
+
+export function getAlternates(path: string, currentLang: string) {
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://weftmap.com";
+
+ // cleanPath should start with slash if path is not empty, otherwise empty string
+ const cleanPath = path ? `/${path}` : "";
+
+ const languages = locales.reduce((acc, locale) => {
+ acc[locale] = `${baseUrl}/${locale}${cleanPath}`;
+ return acc;
+ }, {} as Record);
+
+ // Set default fallback language version to English
+ languages["x-default"] = `${baseUrl}/en${cleanPath}`;
+
+ return {
+ canonical: `${baseUrl}/${currentLang}${cleanPath}`,
+ languages,
+ };
+}