@jjlmoya/utils-travel 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/package.json +59 -58
  2. package/src/category/i18n/de.ts +185 -0
  3. package/src/category/i18n/fr.ts +86 -1
  4. package/src/category/i18n/id.ts +185 -0
  5. package/src/category/i18n/it.ts +185 -0
  6. package/src/category/i18n/ja.ts +181 -0
  7. package/src/category/i18n/ko.ts +181 -0
  8. package/src/category/i18n/nl.ts +185 -0
  9. package/src/category/i18n/pl.ts +181 -0
  10. package/src/category/i18n/pt.ts +181 -0
  11. package/src/category/i18n/ru.ts +181 -0
  12. package/src/category/i18n/sv.ts +181 -0
  13. package/src/category/i18n/tr.ts +181 -0
  14. package/src/category/i18n/zh.ts +181 -0
  15. package/src/category/index.ts +13 -1
  16. package/src/tests/i18n_coverage.test.ts +36 -0
  17. package/src/tests/locale_completeness.test.ts +24 -3
  18. package/src/tests/no_h1_in_components.test.ts +42 -2
  19. package/src/tests/schemas_fulfillment.test.ts +23 -0
  20. package/src/tests/slug_uniqueness.test.ts +81 -0
  21. package/src/tests/title_quality.test.ts +55 -0
  22. package/src/tool/luggage-calculator/i18n/de.ts +616 -0
  23. package/src/tool/luggage-calculator/i18n/en.ts +2 -2
  24. package/src/tool/luggage-calculator/i18n/id.ts +616 -0
  25. package/src/tool/luggage-calculator/i18n/it.ts +616 -0
  26. package/src/tool/luggage-calculator/i18n/ja.ts +616 -0
  27. package/src/tool/luggage-calculator/i18n/ko.ts +616 -0
  28. package/src/tool/luggage-calculator/i18n/nl.ts +616 -0
  29. package/src/tool/luggage-calculator/i18n/pl.ts +616 -0
  30. package/src/tool/luggage-calculator/i18n/pt.ts +616 -0
  31. package/src/tool/luggage-calculator/i18n/ru.ts +616 -0
  32. package/src/tool/luggage-calculator/i18n/sv.ts +616 -0
  33. package/src/tool/luggage-calculator/i18n/tr.ts +616 -0
  34. package/src/tool/luggage-calculator/i18n/zh.ts +616 -0
  35. package/src/tool/luggage-calculator/index.ts +13 -1
  36. package/src/tool/mini-adventures/i18n/de.ts +161 -0
  37. package/src/tool/mini-adventures/i18n/en.ts +1 -1
  38. package/src/tool/mini-adventures/i18n/es.ts +1 -1
  39. package/src/tool/mini-adventures/i18n/id.ts +161 -0
  40. package/src/tool/mini-adventures/i18n/it.ts +161 -0
  41. package/src/tool/mini-adventures/i18n/ja.ts +161 -0
  42. package/src/tool/mini-adventures/i18n/ko.ts +161 -0
  43. package/src/tool/mini-adventures/i18n/nl.ts +161 -0
  44. package/src/tool/mini-adventures/i18n/pl.ts +161 -0
  45. package/src/tool/mini-adventures/i18n/pt.ts +161 -0
  46. package/src/tool/mini-adventures/i18n/ru.ts +161 -0
  47. package/src/tool/mini-adventures/i18n/sv.ts +161 -0
  48. package/src/tool/mini-adventures/i18n/tr.ts +161 -0
  49. package/src/tool/mini-adventures/i18n/zh.ts +161 -0
  50. package/src/tool/mini-adventures/index.ts +13 -1
  51. package/src/tool/suitcase-checklist/i18n/de.ts +261 -0
  52. package/src/tool/suitcase-checklist/i18n/en.ts +1 -1
  53. package/src/tool/suitcase-checklist/i18n/es.ts +1 -1
  54. package/src/tool/suitcase-checklist/i18n/fr.ts +1 -1
  55. package/src/tool/suitcase-checklist/i18n/id.ts +261 -0
  56. package/src/tool/suitcase-checklist/i18n/it.ts +261 -0
  57. package/src/tool/suitcase-checklist/i18n/ja.ts +261 -0
  58. package/src/tool/suitcase-checklist/i18n/ko.ts +261 -0
  59. package/src/tool/suitcase-checklist/i18n/nl.ts +261 -0
  60. package/src/tool/suitcase-checklist/i18n/pl.ts +261 -0
  61. package/src/tool/suitcase-checklist/i18n/pt.ts +261 -0
  62. package/src/tool/suitcase-checklist/i18n/ru.ts +261 -0
  63. package/src/tool/suitcase-checklist/i18n/sv.ts +261 -0
  64. package/src/tool/suitcase-checklist/i18n/tr.ts +261 -0
  65. package/src/tool/suitcase-checklist/i18n/zh.ts +261 -0
  66. package/src/tool/suitcase-checklist/index.ts +13 -1
  67. package/src/tool/tip-calculator/i18n/de.ts +264 -0
  68. package/src/tool/tip-calculator/i18n/id.ts +264 -0
  69. package/src/tool/tip-calculator/i18n/it.ts +264 -0
  70. package/src/tool/tip-calculator/i18n/ja.ts +264 -0
  71. package/src/tool/tip-calculator/i18n/ko.ts +264 -0
  72. package/src/tool/tip-calculator/i18n/nl.ts +264 -0
  73. package/src/tool/tip-calculator/i18n/pl.ts +264 -0
  74. package/src/tool/tip-calculator/i18n/pt.ts +264 -0
  75. package/src/tool/tip-calculator/i18n/ru.ts +264 -0
  76. package/src/tool/tip-calculator/i18n/sv.ts +264 -0
  77. package/src/tool/tip-calculator/i18n/tr.ts +264 -0
  78. package/src/tool/tip-calculator/i18n/zh.ts +264 -0
  79. package/src/tool/tip-calculator/index.ts +13 -1
  80. package/src/tool/optimal-routes/bibliography.astro +0 -14
  81. package/src/tool/optimal-routes/component.astro +0 -439
  82. package/src/tool/optimal-routes/i18n/en.ts +0 -42
  83. package/src/tool/optimal-routes/i18n/es.ts +0 -52
  84. package/src/tool/optimal-routes/index.ts +0 -63
  85. package/src/tool/optimal-routes/lib/RouteManager.ts +0 -181
  86. package/src/tool/optimal-routes/seo.astro +0 -14
@@ -0,0 +1,181 @@
1
+ import type { CategoryLocaleContent } from '../../types';
2
+
3
+ export const content: CategoryLocaleContent = {
4
+ slug: 'lv-xing',
5
+ title: '旅行者和冒险者的工具与计算器',
6
+ description: '使用免费在线工具规划您的下一次旅行。航空行李计算器、国际小费指南、行李清单以及每日挑战生成器。',
7
+ seo: [
8
+ {
9
+ type: 'title',
10
+ text: '旅行物流与当地探索:智慧规划',
11
+ level: 2,
12
+ },
13
+ {
14
+ type: 'paragraph',
15
+ html: '2026 年的旅行是探索与技术管理的融合。在本节中,我们为希望减轻规划压力的数字游民、常旅客和城市冒险者提供了一套<strong>免费在线工具</strong>。旅行物流不应成为冒险的障碍,而应是使之成为可能的地图。',
16
+ },
17
+ {
18
+ type: 'paragraph',
19
+ html: '从遵守航空公司规定到国际文化礼仪以及当地环境的游戏化,我们的工具帮助您成为一个更有意识、更高效、更有好奇心的旅行者。',
20
+ },
21
+ {
22
+ type: 'title',
23
+ text: '航空物流:行李计算器与尺寸',
24
+ level: 2,
25
+ },
26
+ {
27
+ type: 'paragraph',
28
+ html: '避免在值机柜台出现意外和额外费用至关重要。我们的<strong>行李计算器</strong>集成了 20 多家领先航空公司(包括瑞安航空、易捷航空或伏林航空等廉价航空公司)允许的尺寸和重量。立即验证您的随身行李是否符合下一次航班的具体要求。',
29
+ },
30
+ {
31
+ type: 'title',
32
+ text: '国际礼仪:小费指南与计算器',
33
+ level: 2,
34
+ },
35
+ {
36
+ type: 'paragraph',
37
+ html: '支付小费不只是一次交易;它是一种在不同国家间迥异的文化规范。我们的<strong>国际小费计算器</strong>提供针对 50 多个国家的建议百分比和小费礼仪,帮助您在从日本到美国的旅途中成为尊重当地习俗的旅行者。',
38
+ },
39
+ {
40
+ type: 'title',
41
+ text: '高效组织:行李清单生成器',
42
+ level: 2,
43
+ },
44
+ {
45
+ type: 'paragraph',
46
+ html: '您是否曾到达目的地后才意识到忘记了最重要的东西?根据您的停留时间、目的地的气候和旅行原因,我们的<strong>交互式清单</strong>会生成个性化的行李清单,让您不再听天由命。',
47
+ },
48
+ {
49
+ type: 'title',
50
+ text: '环境探索:微型冒险生成器',
51
+ level: 2,
52
+ },
53
+ {
54
+ type: 'paragraph',
55
+ html: '冒险不仅在世界的另一端;它通常就在转角处。我们的<strong>微型冒险</strong>生成器使用随机算法来建议每日挑战,迫使您以创造性和不同的方式与当地环境互动。打破常规,重新发现您自己的城市。',
56
+ },
57
+ {
58
+ type: 'list',
59
+ items: [
60
+ '<strong>飞行节省:</strong>通过技术规划,避免因超重行李或超大行李箱产生额外费用。',
61
+ '<strong>文化意识:</strong>通过了解当地礼仪规范,自信地穿梭于国际环境。',
62
+ '<strong>安全出行:</strong>确保您携带所有必要证件和药物的清单。',
63
+ '<strong>探索游戏化:</strong>将您的日常通勤转化为小型发现探险。',
64
+ ],
65
+ },
66
+ {
67
+ type: 'tip',
68
+ title: '专家旅行建议',
69
+ html: '<p><strong>证件保管:</strong>始终在云端保存一份加密的证件(护照、保险)电子副本,并在行李中放置一份隐藏的纸质副本。我们的清单工具会提醒您在出门前完成这一关键步骤。</p>',
70
+ },
71
+ {
72
+ type: 'title',
73
+ text: '财务规划:预算编制与开支控制',
74
+ level: 2,
75
+ },
76
+ {
77
+ type: 'paragraph',
78
+ html: '没有预算的旅行是被旅行控制而非控制旅行。出发前,建立按类别细分的现实预算:住宿(40%)、餐饮(25%)、活动(20%)、当地交通(10%)、紧急预备金(5%)。这种结构可以防止令人不快的意外。',
79
+ },
80
+ {
81
+ type: 'title',
82
+ text: '国际旅行中的健康与医疗证件',
83
+ level: 2,
84
+ },
85
+ {
86
+ type: 'paragraph',
87
+ html: '健康随您而行。在前往任何国际目的地之前,检查建议接种的疫苗。随身携带数字化病历、英文药物处方以及在必要时涵盖医疗转运的旅游保险。',
88
+ },
89
+ {
90
+ type: 'title',
91
+ text: '真实体验:负责任旅游与剥削',
92
+ level: 2,
93
+ },
94
+ {
95
+ type: 'paragraph',
96
+ html: '负责任的旅行意味着事先研究、尊重当地边界,并直接向当地社区而非中间商提供经济支持。这会拓宽您的体验并创造真正的积极影响。',
97
+ },
98
+ {
99
+ type: 'title',
100
+ text: '住宿物流与智能预订',
101
+ level: 2,
102
+ },
103
+ {
104
+ type: 'paragraph',
105
+ html: '我们的<strong>住宿比较器</strong>实时聚合多个平台的价格,允许您评估不仅是价格,还有经过验证的评价和战略位置。',
106
+ },
107
+ {
108
+ type: 'title',
109
+ text: '货币兑换与历史汇率分析',
110
+ level: 2,
111
+ },
112
+ {
113
+ type: 'paragraph',
114
+ html: '我们的<strong>汇率追踪器</strong>向您展示历史汇率,以便您规划最佳的换钱时间。',
115
+ },
116
+ {
117
+ type: 'title',
118
+ text: '时差管理与科学时间适应',
119
+ level: 2,
120
+ },
121
+ {
122
+ type: 'paragraph',
123
+ html: '我们的<strong>时差规划器</strong>根据您的目的地建议何时进行日光照射。简单的策略(如在旅行前 2-3 天调整睡眠)可以显著减轻时差反应。',
124
+ },
125
+ {
126
+ type: 'title',
127
+ text: '路由优化:应用于旅游的旅行商算法',
128
+ level: 2,
129
+ },
130
+ {
131
+ type: 'paragraph',
132
+ html: '我们的<strong>旅游路线优化器</strong>应用旅行商问题 (TSP) 算法来寻找最有效的访问顺序。它不仅考虑距离,还考虑停留时间、营业时间和可用的公共交通。',
133
+ },
134
+ {
135
+ type: 'title',
136
+ text: '旅行规定:签证、疫苗和所需证件',
137
+ level: 2,
138
+ },
139
+ {
140
+ type: 'paragraph',
141
+ html: '我们的<strong>旅行要求审计器</strong>检查您的国籍、目的地和旅行日期,准确告知您需要哪些证件。',
142
+ },
143
+ {
144
+ type: 'title',
145
+ text: '当地交通:地铁图与全球交通系统',
146
+ level: 2,
147
+ },
148
+ {
149
+ type: 'paragraph',
150
+ html: '我们的<strong>公共交通导航器</strong>集成了全球 100 多个城市的地铁、公交和有轨电车系统。',
151
+ },
152
+ {
153
+ type: 'title',
154
+ text: '旅游保险与医疗紧急覆盖',
155
+ level: 2,
156
+ },
157
+ {
158
+ type: 'paragraph',
159
+ html: '<strong>旅游保险比较器</strong>允许您评估医疗转运、牙科护理和行李报销的覆盖范围。',
160
+ },
161
+ {
162
+ type: 'title',
163
+ text: '2026 年感性旅游的未来',
164
+ level: 2,
165
+ },
166
+ {
167
+ type: 'paragraph',
168
+ html: '在 2026 年,<strong>慢旅行</strong>和再生旅游是基础。这些工具赋予您管理作为旅行者自主权的能力,减少物流噪音。',
169
+ },
170
+ {
171
+ type: 'stats',
172
+ columns: 2,
173
+ items: [
174
+ { label: '行李', value: '20+ 航空公司', icon: 'mdi:airplane' },
175
+ { label: '清单', value: '智能自适应', icon: 'mdi:clipboard-check' },
176
+ { label: '小费', value: '50+ 国家', icon: 'mdi:cash-multiple' },
177
+ { label: '冒险', value: '每日生物学', icon: 'mdi:compass' },
178
+ ],
179
+ },
180
+ ],
181
+ };
@@ -4,9 +4,21 @@ export const travelCategory: TravelCategoryEntry = {
4
4
  icon: 'mdi:map-marker-path',
5
5
  tools: [],
6
6
  i18n: {
7
- es: () => import('./i18n/es').then((m) => m.content),
7
+ de: () => import('./i18n/de').then((m) => m.content),
8
8
  en: () => import('./i18n/en').then((m) => m.content),
9
+ es: () => import('./i18n/es').then((m) => m.content),
9
10
  fr: () => import('./i18n/fr').then((m) => m.content),
11
+ id: () => import('./i18n/id').then((m) => m.content),
12
+ it: () => import('./i18n/it').then((m) => m.content),
13
+ ja: () => import('./i18n/ja').then((m) => m.content),
14
+ ko: () => import('./i18n/ko').then((m) => m.content),
15
+ nl: () => import('./i18n/nl').then((m) => m.content),
16
+ pl: () => import('./i18n/pl').then((m) => m.content),
17
+ pt: () => import('./i18n/pt').then((m) => m.content),
18
+ ru: () => import('./i18n/ru').then((m) => m.content),
19
+ sv: () => import('./i18n/sv').then((m) => m.content),
20
+ tr: () => import('./i18n/tr').then((m) => m.content),
21
+ zh: () => import('./i18n/zh').then((m) => m.content),
10
22
  },
11
23
  };
12
24
 
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const EXPECTED_LOCALES = [
5
+ 'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'
6
+ ];
7
+
8
+ describe('I18n Coverage Validation', () => {
9
+ it('all tools should be registered', () => {
10
+ expect(ALL_TOOLS.length).toBeGreaterThan(0);
11
+ });
12
+
13
+ ALL_TOOLS.forEach(({ entry }: { entry: any }) => {
14
+ describe(`Tool: ${entry.id}`, () => {
15
+ it('should have all 15 required locales', () => {
16
+ const registeredLocales = Object.keys(entry.i18n);
17
+ EXPECTED_LOCALES.forEach((locale) => {
18
+ expect(
19
+ registeredLocales,
20
+ `Tool "${entry.id}" is missing locale "${locale}"`,
21
+ ).toContain(locale);
22
+ });
23
+ });
24
+
25
+ it('all locale loaders should be functions', () => {
26
+ EXPECTED_LOCALES.forEach((locale) => {
27
+ const loader = entry.i18n[locale as keyof typeof entry.i18n];
28
+ expect(
29
+ typeof loader,
30
+ `Tool "${entry.id}" locale "${locale}" loader is not a function`,
31
+ ).toBe('function');
32
+ });
33
+ });
34
+ });
35
+ });
36
+ });
@@ -1,20 +1,41 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { ALL_TOOLS } from '../tools';
3
+ import type { ToolLocaleContent } from '../types';
3
4
 
4
5
  describe('Locale Completeness Validation', () => {
5
6
  ALL_TOOLS.forEach((tool) => {
6
7
  describe(`Tool: ${tool.entry.id}`, () => {
7
8
  Object.keys(tool.entry.i18n).forEach((locale) => {
8
9
  describe(`Locale: ${locale}`, () => {
9
- it('placeholder', () => {
10
- expect(true).toBe(true);
10
+ it('faqTitle should be defined when faq items exist', async () => {
11
+ const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
12
+ const content = (await loader?.()) as ToolLocaleContent;
13
+
14
+ if (content.faq.length > 0) {
15
+ expect(
16
+ content.faqTitle,
17
+ `Tool "${tool.entry.id}" locale "${locale}" has ${content.faq.length} FAQ items but is missing faqTitle`,
18
+ ).toBeTruthy();
19
+ }
20
+ });
21
+
22
+ it('bibliographyTitle should be defined when bibliography items exist', async () => {
23
+ const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
24
+ const content = (await loader?.()) as ToolLocaleContent;
25
+
26
+ if (content.bibliography.length > 0) {
27
+ expect(
28
+ content.bibliographyTitle,
29
+ `Tool "${tool.entry.id}" locale "${locale}" has ${content.bibliography.length} bibliography items but is missing bibliographyTitle`,
30
+ ).toBeTruthy();
31
+ }
11
32
  });
12
33
  });
13
34
  });
14
35
  });
15
36
  });
16
37
 
17
- it('tools are registered', () => {
38
+ it('all 4 tools registered', () => {
18
39
  expect(ALL_TOOLS.length).toBe(4);
19
40
  });
20
41
  });
@@ -1,8 +1,48 @@
1
1
  import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ const EXCLUDED_DIRS = ['node_modules', 'pages', 'layouts'];
6
+
7
+ function findAstroFiles(dir: string): string[] {
8
+ const files: string[] = [];
9
+ const entries = readdirSync(dir, { withFileTypes: true });
10
+
11
+ for (const entry of entries) {
12
+ const fullPath = join(dir, entry.name);
13
+ if (entry.isDirectory() && !EXCLUDED_DIRS.includes(entry.name)) {
14
+ files.push(...findAstroFiles(fullPath));
15
+ } else if (entry.isFile() && entry.name.endsWith('.astro')) {
16
+ files.push(fullPath);
17
+ }
18
+ }
19
+
20
+ return files;
21
+ }
22
+
23
+ function hasH1(content: string): boolean {
24
+ return /<h1[\s>]/i.test(content);
25
+ }
26
+
27
+ const srcDir = join(process.cwd(), 'src');
28
+ const astroFiles = findAstroFiles(srcDir);
2
29
 
3
30
  describe('No H1 in Components', () => {
4
- it('placeholder', () => {
5
- expect(true).toBe(true);
31
+ if (astroFiles.length === 0) {
32
+ it('no astro components found', () => {
33
+ expect(true).toBe(true);
34
+ });
35
+ }
36
+
37
+ astroFiles.forEach((file) => {
38
+ const relativePath = file.replace(process.cwd(), '');
39
+ it(`${relativePath} should not contain <h1>`, () => {
40
+ const content = readFileSync(file, 'utf-8');
41
+ expect(
42
+ hasH1(content),
43
+ `File "${relativePath}" contains a <h1> element. Use <h2> or lower inside components — h1 belongs to the page layout.`,
44
+ ).toBe(false);
45
+ });
6
46
  });
7
47
  });
8
48
 
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import type { ToolLocaleContent } from '../types';
4
+
5
+ describe('Schemas Fulfillment Validation', () => {
6
+ ALL_TOOLS.forEach((tool) => {
7
+ describe(`Tool: ${tool.entry.id}`, () => {
8
+ Object.keys(tool.entry.i18n).forEach((locale) => {
9
+ it(`Locale: ${locale} should have faqSchema, appSchema and howToSchema`, async () => {
10
+ const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
11
+ if (!loader) return;
12
+ const content = (await loader()) as ToolLocaleContent;
13
+
14
+ const schemaTypes = content.schemas.map((s: any) => s['@type']);
15
+
16
+ expect(schemaTypes, `Tool "${tool.entry.id}" locale "${locale}" is missing FAQPage schema`).toContain('FAQPage');
17
+ expect(schemaTypes, `Tool "${tool.entry.id}" locale "${locale}" is missing SoftwareApplication schema`).toContain('SoftwareApplication');
18
+ expect(schemaTypes, `Tool "${tool.entry.id}" locale "${locale}" is missing HowTo schema`).toContain('HowTo');
19
+ });
20
+ });
21
+ });
22
+ });
23
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import type { ToolLocaleContent } from '../types';
4
+
5
+ const sharingLocales = ['ja', 'ko', 'zh'];
6
+
7
+ interface ValidateParams {
8
+ toolId: string;
9
+ locale: string;
10
+ content: ToolLocaleContent;
11
+ enSlug: string;
12
+ slugs: Map<string, string>;
13
+ }
14
+
15
+ const validateLocaleSlug = ({
16
+ toolId,
17
+ locale,
18
+ content,
19
+ enSlug,
20
+ slugs,
21
+ }: ValidateParams) => {
22
+ expect(
23
+ content.slug,
24
+ `Tool "${toolId}" locale "${locale}" has an invalid slug ("${content.slug}"). Slugs must be transliterated (only a-z, 0-9, and -).`,
25
+ ).toMatch(/^[a-z0-9-]+$/);
26
+
27
+ if (locale === 'en') {
28
+ return;
29
+ }
30
+
31
+ if (sharingLocales.includes(locale)) {
32
+ expect(
33
+ content.slug,
34
+ `Tool "${toolId}" locale "${locale}" must use the same slug as "en" ("${enSlug}").`,
35
+ ).toBe(enSlug);
36
+ } else {
37
+ expect(
38
+ content.slug,
39
+ `Tool "${toolId}" locale "${locale}" has the same slug as "en" ("${enSlug}"). Cada slug tiene que estar en su propia idioma`,
40
+ ).not.toBe(enSlug);
41
+
42
+ if (slugs.has(content.slug)) {
43
+ const previousLocale = slugs.get(content.slug);
44
+ expect(
45
+ false,
46
+ `Tool "${toolId}" locales "${locale}" and "${previousLocale}" share the same slug ("${content.slug}"). Cada slug tiene que estar en su propia idioma`,
47
+ ).toBe(true);
48
+ }
49
+ slugs.set(content.slug, locale);
50
+ }
51
+ };
52
+
53
+ describe('Slug Localization and Uniqueness Validation', () => {
54
+ ALL_TOOLS.forEach((tool) => {
55
+ describe(`Tool: ${tool.entry.id}`, () => {
56
+ it('every locale should have a unique, translated slug', async () => {
57
+ const slugs = new Map<string, string>();
58
+ const locales = Object.keys(tool.entry.i18n);
59
+
60
+ let enSlug = '';
61
+ if (locales.includes('en')) {
62
+ const enLoader = tool.entry.i18n['en' as keyof typeof tool.entry.i18n];
63
+ const enContent = (await enLoader?.()) as ToolLocaleContent;
64
+ enSlug = enContent.slug;
65
+ }
66
+
67
+ for (const locale of locales) {
68
+ const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
69
+ const content = (await loader?.()) as ToolLocaleContent;
70
+ validateLocaleSlug({
71
+ toolId: tool.entry.id,
72
+ locale,
73
+ content,
74
+ enSlug,
75
+ slugs,
76
+ });
77
+ }
78
+ });
79
+ });
80
+ });
81
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ function getFiles(dir: string, ext: string[]): string[] {
6
+ const results: string[] = [];
7
+ if (!fs.existsSync(dir)) return results;
8
+ const list = fs.readdirSync(dir);
9
+ for (const file of list) {
10
+ const fullPath = path.join(dir, file);
11
+ const stat = fs.statSync(fullPath);
12
+ if (stat && stat.isDirectory()) {
13
+ results.push(...getFiles(fullPath, ext));
14
+ } else if (ext.some((e) => file.endsWith(e))) {
15
+ results.push(fullPath);
16
+ }
17
+ }
18
+ return results;
19
+ }
20
+
21
+ const SRC_DIR = path.join(process.cwd(), 'src');
22
+
23
+ describe('Project Titles - Separator Validation', () => {
24
+ const files = [
25
+ ...getFiles(path.join(SRC_DIR, 'tool'), ['.ts']),
26
+ ...getFiles(path.join(SRC_DIR, 'category'), ['.ts']),
27
+ ].filter(f => f.includes('i18n'));
28
+
29
+ it.each(files)('Verify that titles in %s do not contain | or -', (filePath) => {
30
+ const content = fs.readFileSync(filePath, 'utf-8');
31
+ const relativePath = path.relative(process.cwd(), filePath);
32
+
33
+ const titlePatterns = [
34
+ /const\s+title\s*=\s*['"]([^'"]+)['"]/g,
35
+ /title\s*:\s*['"]([^'"]+)['"]/g,
36
+ ];
37
+
38
+ const findings: string[] = [];
39
+
40
+ for (const pattern of titlePatterns) {
41
+ let match;
42
+ while ((match = pattern.exec(content)) !== null) {
43
+ const title = match[1];
44
+ if (title.includes('|') || title.includes('-')) {
45
+ findings.push(title);
46
+ }
47
+ }
48
+ }
49
+
50
+ if (findings.length > 0) {
51
+ const list = findings.map((f) => ` - "${f}"`).join('\n');
52
+ throw new Error(`Forbidden separators (| or -) found in titles in ${relativePath}:\n${list}`);
53
+ }
54
+ });
55
+ });