@jjlmoya/utils-finance 1.19.0 → 1.21.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 (94) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/fr.ts +9 -9
  3. package/src/category/i18n/ru.ts +4 -4
  4. package/src/layouts/PreviewLayout.astro +1 -0
  5. package/src/tests/diacritics_density.test.ts +140 -0
  6. package/src/tests/inverted_punctuation.test.ts +95 -0
  7. package/src/tests/no_en_dash.test.ts +70 -0
  8. package/src/tests/script_density.test.ts +94 -0
  9. package/src/tool/compoundInterest/compound-interest-calculator.css +75 -2
  10. package/src/tool/compoundInterest/i18n/de.ts +4 -4
  11. package/src/tool/compoundInterest/i18n/fr.ts +9 -9
  12. package/src/tool/compoundInterest/i18n/it.ts +18 -18
  13. package/src/tool/compoundInterest/i18n/pl.ts +2 -2
  14. package/src/tool/compoundInterest/i18n/ru.ts +7 -7
  15. package/src/tool/compoundInterest/i18n/tr.ts +7 -7
  16. package/src/tool/compoundInterest/i18n/zh.ts +4 -4
  17. package/src/tool/courtFeeCalculator/i18n/it.ts +10 -10
  18. package/src/tool/courtFeeCalculator/i18n/ru.ts +2 -2
  19. package/src/tool/courtFeeCalculator/i18n/tr.ts +6 -6
  20. package/src/tool/courtFeeCalculator/spanish-court-fees-calculator.css +63 -2
  21. package/src/tool/debtSnowball/bibliography.ts +2 -2
  22. package/src/tool/debtSnowball/i18n/it.ts +2 -2
  23. package/src/tool/debtSnowball/i18n/pl.ts +1 -1
  24. package/src/tool/debtSnowball/i18n/tr.ts +1 -1
  25. package/src/tool/debtSnowball/i18n/zh.ts +1 -1
  26. package/src/tool/financialFreedom/financial-freedom-calculator.css +89 -0
  27. package/src/tool/financialFreedom/i18n/en.ts +1 -1
  28. package/src/tool/financialFreedom/i18n/fr.ts +3 -3
  29. package/src/tool/financialFreedom/i18n/ru.ts +2 -2
  30. package/src/tool/financialFreedom/i18n/zh.ts +1 -1
  31. package/src/tool/fireCalculator/i18n/it.ts +14 -14
  32. package/src/tool/fireCalculator/i18n/ru.ts +6 -6
  33. package/src/tool/fireCalculator/i18n/tr.ts +6 -6
  34. package/src/tool/fireCalculator/i18n/zh.ts +3 -3
  35. package/src/tool/ibanBicSwiftConverter/component.astro +2 -1
  36. package/src/tool/ibanBicSwiftConverter/i18n/fr.ts +1 -1
  37. package/src/tool/ibanBicSwiftConverter/i18n/it.ts +7 -7
  38. package/src/tool/ibanBicSwiftConverter/i18n/ru.ts +1 -1
  39. package/src/tool/ibanBicSwiftConverter/i18n/tr.ts +5 -5
  40. package/src/tool/ibanBicSwiftConverter/iban-to-bic-swift-converter.css +64 -3
  41. package/src/tool/inflation/i18n/de.ts +2 -2
  42. package/src/tool/inflation/i18n/fr.ts +1 -1
  43. package/src/tool/inflation/i18n/it.ts +32 -32
  44. package/src/tool/inflation/i18n/nl.ts +7 -7
  45. package/src/tool/inflation/i18n/pl.ts +2 -2
  46. package/src/tool/inflation/i18n/pt.ts +2 -2
  47. package/src/tool/inflation/i18n/ru.ts +6 -6
  48. package/src/tool/inflation/i18n/sv.ts +3 -3
  49. package/src/tool/inflation/i18n/tr.ts +14 -14
  50. package/src/tool/inflation/i18n/zh.ts +2 -2
  51. package/src/tool/lateInterest/i18n/de.ts +2 -2
  52. package/src/tool/lateInterest/i18n/es.ts +5 -5
  53. package/src/tool/lateInterest/i18n/it.ts +14 -14
  54. package/src/tool/lateInterest/i18n/nl.ts +1 -1
  55. package/src/tool/lateInterest/i18n/pl.ts +1 -1
  56. package/src/tool/lateInterest/i18n/pt.ts +1 -1
  57. package/src/tool/lateInterest/i18n/ru.ts +4 -4
  58. package/src/tool/lateInterest/i18n/sv.ts +3 -3
  59. package/src/tool/lateInterest/i18n/tr.ts +7 -7
  60. package/src/tool/lateInterest/i18n/zh.ts +1 -1
  61. package/src/tool/legalInterestRate/i18n/it.ts +15 -15
  62. package/src/tool/legalInterestRate/i18n/ru.ts +1 -1
  63. package/src/tool/legalInterestRate/i18n/tr.ts +8 -8
  64. package/src/tool/lotteryOptimizer/i18n/it.ts +7 -7
  65. package/src/tool/lotteryOptimizer/i18n/pl.ts +1 -1
  66. package/src/tool/lotteryOptimizer/i18n/ru.ts +4 -4
  67. package/src/tool/lotteryOptimizer/i18n/tr.ts +4 -4
  68. package/src/tool/lotteryOptimizer/i18n/zh.ts +3 -3
  69. package/src/tool/mortgage/i18n/de.ts +1 -1
  70. package/src/tool/mortgage/i18n/fr.ts +4 -4
  71. package/src/tool/mortgage/i18n/id.ts +1 -1
  72. package/src/tool/mortgage/i18n/it.ts +12 -12
  73. package/src/tool/mortgage/i18n/nl.ts +2 -2
  74. package/src/tool/mortgage/i18n/pl.ts +1 -1
  75. package/src/tool/mortgage/i18n/ru.ts +5 -5
  76. package/src/tool/mortgage/i18n/tr.ts +6 -6
  77. package/src/tool/mortgage/i18n/zh.ts +1 -1
  78. package/src/tool/mortgage/mortgage-calculator.css +58 -1
  79. package/src/tool/percentageCalculator/i18n/de.ts +4 -4
  80. package/src/tool/percentageCalculator/i18n/en.ts +1 -1
  81. package/src/tool/percentageCalculator/i18n/es.ts +2 -2
  82. package/src/tool/percentageCalculator/i18n/fr.ts +2 -2
  83. package/src/tool/percentageCalculator/i18n/it.ts +22 -22
  84. package/src/tool/percentageCalculator/i18n/pl.ts +4 -4
  85. package/src/tool/percentageCalculator/i18n/ru.ts +9 -9
  86. package/src/tool/percentageCalculator/i18n/tr.ts +18 -18
  87. package/src/tool/percentageCalculator/i18n/zh.ts +6 -6
  88. package/src/tool/profitability/i18n/fr.ts +2 -2
  89. package/src/tool/profitability/i18n/ru.ts +1 -1
  90. package/src/tool/profitability/i18n/zh.ts +2 -2
  91. package/src/tool/profitability/profitability-calculator.css +101 -0
  92. package/src/tool/rentIncreaseCalculator/i18n/it.ts +13 -13
  93. package/src/tool/rentIncreaseCalculator/i18n/tr.ts +5 -5
  94. package/src/tool/rentIncreaseCalculator/rent-increase-calculator.css +44 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-finance",
3
- "version": "1.19.0",
3
+ "version": "1.21.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -8,7 +8,7 @@ export const content: CategoryLocaleContent = {
8
8
  seo: [
9
9
  {
10
10
  type: 'title',
11
- text: "Ingénierie Financière Personnelle : Décisions Basées sur les Mathématiques",
11
+ text: "Ingénierie Financière Personnelle: Décisions Basées sur les Mathématiques",
12
12
  level: 2,
13
13
  },
14
14
  {
@@ -21,7 +21,7 @@ export const content: CategoryLocaleContent = {
21
21
  },
22
22
  {
23
23
  type: 'title',
24
- text: "Investissement et Patrimoine : Le Pouvoir des Intérêts Composés",
24
+ text: "Investissement et Patrimoine: Le Pouvoir des Intérêts Composés",
25
25
  level: 2,
26
26
  },
27
27
  {
@@ -34,7 +34,7 @@ export const content: CategoryLocaleContent = {
34
34
  },
35
35
  {
36
36
  type: 'title',
37
- text: 'Finances du Foyer : Prêts Immobiliers et Loyers',
37
+ text: 'Finances du Foyer: Prêts Immobiliers et Loyers',
38
38
  level: 2,
39
39
  },
40
40
  {
@@ -75,16 +75,16 @@ export const content: CategoryLocaleContent = {
75
75
  },
76
76
  {
77
77
  type: 'title',
78
- text: 'Diversification et Gestion du Risque : Portefeuille Optimal',
78
+ text: 'Diversification et Gestion du Risque: Portefeuille Optimal',
79
79
  level: 2,
80
80
  },
81
81
  {
82
82
  type: 'paragraph',
83
- html: "Le risque ne peut pas être éliminé, mais il peut être géré. La diversification est la stratégie fondamentale : ne pas mettre tous ses œufs dans le même panier. Nos outils permettent de modéliser un portefeuille hypothétique avec différentes proportions (60% actions, 30% obligations, 10% liquidités).",
83
+ html: "Le risque ne peut pas être éliminé, mais il peut être géré. La diversification est la stratégie fondamentale: ne pas mettre tous ses œufs dans le même panier. Nos outils permettent de modéliser un portefeuille hypothétique avec différentes proportions (60% actions, 30% obligations, 10% liquidités).",
84
84
  },
85
85
  {
86
86
  type: 'title',
87
- text: 'Analyse de Rentabilité : ROI et Période de Récupération',
87
+ text: 'Analyse de Rentabilité: ROI et Période de Récupération',
88
88
  level: 2,
89
89
  },
90
90
  {
@@ -93,7 +93,7 @@ export const content: CategoryLocaleContent = {
93
93
  },
94
94
  {
95
95
  type: 'title',
96
- text: 'Fiscalité et Déductions : Optimisation Fiscale',
96
+ text: 'Fiscalité et Déductions: Optimisation Fiscale',
97
97
  level: 2,
98
98
  },
99
99
  {
@@ -102,7 +102,7 @@ export const content: CategoryLocaleContent = {
102
102
  },
103
103
  {
104
104
  type: 'title',
105
- text: 'Cryptomonnaies et Actifs Numériques : Conversion et Valorisation',
105
+ text: 'Cryptomonnaies et Actifs Numériques: Conversion et Valorisation',
106
106
  level: 2,
107
107
  },
108
108
  {
@@ -111,7 +111,7 @@ export const content: CategoryLocaleContent = {
111
111
  },
112
112
  {
113
113
  type: 'title',
114
- text: 'Planification de la Retraite Anticipée : FIRE',
114
+ text: 'Planification de la Retraite Anticipée: FIRE',
115
115
  level: 2,
116
116
  },
117
117
  {
@@ -70,7 +70,7 @@ export const content: CategoryLocaleContent = {
70
70
  {
71
71
  type: 'tip',
72
72
  title: 'Стратегический Совет по Сбережениям',
73
- html: '<p><strong>Стратегия 2026:</strong> В условиях устойчивой инфляции хранение наличных гарантированный убыток. Используйте симулятор сложных процентов, чтобы оценить, как небольшое увеличение доходности кардинально влияет на вашу пенсию.</p>',
73
+ html: '<p><strong>Стратегия 2026:</strong> В условиях устойчивой инфляции хранение наличных - гарантированный убыток. Используйте симулятор сложных процентов, чтобы оценить, как небольшое увеличение доходности кардинально влияет на вашу пенсию.</p>',
74
74
  },
75
75
  {
76
76
  type: 'title',
@@ -79,7 +79,7 @@ export const content: CategoryLocaleContent = {
79
79
  },
80
80
  {
81
81
  type: 'paragraph',
82
- html: 'Риск нельзя устранить, но им можно управлять. Диверсификация это фундаментальная стратегия: не кладите все яйца в одну корзину. Наши инструменты позволяют моделировать гипотетический портфель с разными долями (60% акций, 30% облигаций, 10% наличных) и прогнозировать его историческую доходность в разных рыночных сценариях.',
82
+ html: 'Риск нельзя устранить, но им можно управлять. Диверсификация - это фундаментальная стратегия: не кладите все яйца в одну корзину. Наши инструменты позволяют моделировать гипотетический портфель с разными долями (60% акций, 30% облигаций, 10% наличных) и прогнозировать его историческую доходность в разных рыночных сценариях.',
83
83
  },
84
84
  {
85
85
  type: 'title',
@@ -97,7 +97,7 @@ export const content: CategoryLocaleContent = {
97
97
  },
98
98
  {
99
99
  type: 'paragraph',
100
- html: 'Налоги поглощают более половины состояния многих людей. Знание, какие расходы вычитаются, какие инвестиции генерируют отложенные налоги (пенсионные планы) и как получить возврат разница между пассивным и активным налогоплательщиком.',
100
+ html: 'Налоги поглощают более половины состояния многих людей. Знание, какие расходы вычитаются, какие инвестиции генерируют отложенные налоги (пенсионные планы) и как получить возврат - разница между пассивным и активным налогоплательщиком.',
101
101
  },
102
102
  {
103
103
  type: 'title',
@@ -142,7 +142,7 @@ export const content: CategoryLocaleContent = {
142
142
  },
143
143
  {
144
144
  type: 'paragraph',
145
- html: 'Будущее финансов это <strong>самостоятельное хранение и техническое понимание</strong>. Доверять тому, что говорит банк, больше недостаточно. Эти инструменты дают способность проверять каждый цент, каждое начисление процентов и каждый договор, позволяя ориентироваться в финансовой системе с уверенностью, которую даёт математическое знание.',
145
+ html: 'Будущее финансов - это <strong>самостоятельное хранение и техническое понимание</strong>. Доверять тому, что говорит банк, больше недостаточно. Эти инструменты дают способность проверять каждый цент, каждое начисление процентов и каждый договор, позволяя ориентироваться в финансовой системе с уверенностью, которую даёт математическое знание.',
146
146
  },
147
147
  {
148
148
  type: 'stats',
@@ -78,6 +78,7 @@ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Ast
78
78
  transition:
79
79
  background-color 0.3s ease,
80
80
  color 0.3s ease;
81
+ font-family: Inter, sans-serif;
81
82
  }
82
83
 
83
84
  main {
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it, afterAll } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const failures: Map<string, string[]> = new Map();
5
+
6
+ type LocaleWithDiacritics = keyof typeof DIACRITIC_RULES;
7
+
8
+ const DIACRITIC_RULES = {
9
+ de: {
10
+ language: 'German',
11
+ expectedCharacters: 'ä ö ü ß',
12
+ characters: /[äöüÄÖÜß]/g,
13
+ minPerThousandLetters: 0.1,
14
+ },
15
+ es: {
16
+ language: 'Spanish',
17
+ expectedCharacters: 'á é í ó ú ü ñ',
18
+ characters: /[áéíóúüñÁÉÍÓÚÜÑ]/g,
19
+ minPerThousandLetters: 0.1,
20
+ },
21
+ fr: {
22
+ language: 'French',
23
+ expectedCharacters: 'à â æ ç é è ê ë î ï ô œ ù û ü ÿ',
24
+ characters: /[àâæçéèêëîïôœùûüÿÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸ]/g,
25
+ minPerThousandLetters: 0.1,
26
+ },
27
+ it: {
28
+ language: 'Italian',
29
+ expectedCharacters: 'à è é ì í î ò ó ù ú',
30
+ characters: /[àèéìíîòóùúÀÈÉÌÍÎÒÓÙÚ]/g,
31
+ minPerThousandLetters: 0.1,
32
+ },
33
+ pl: {
34
+ language: 'Polish',
35
+ expectedCharacters: 'ą ć ę ł ń ó ś ź ż',
36
+ characters: /[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g,
37
+ minPerThousandLetters: 0.1,
38
+ },
39
+ pt: {
40
+ language: 'Portuguese',
41
+ expectedCharacters: 'á â ã à ç é ê í ó ô õ ú ü',
42
+ characters: /[áâãàçéêíóôõúüÁÂÃÀÇÉÊÍÓÔÕÚÜ]/g,
43
+ minPerThousandLetters: 0.1,
44
+ },
45
+ sv: {
46
+ language: 'Swedish',
47
+ expectedCharacters: 'å ä ö',
48
+ characters: /[åäöÅÄÖ]/g,
49
+ minPerThousandLetters: 0.1,
50
+ },
51
+ tr: {
52
+ language: 'Turkish',
53
+ expectedCharacters: 'ç ğ ı İ ö ş ü',
54
+ characters: /[çğıöşüÇĞİÖŞÜ]/g,
55
+ minPerThousandLetters: 0.1,
56
+ },
57
+ } as const;
58
+
59
+ const LETTERS = /\p{L}/gu;
60
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
61
+
62
+ function collectStrings(value: unknown): string[] {
63
+ if (typeof value === 'string') return [value];
64
+ if (!value || typeof value !== 'object') return [];
65
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
66
+ return Object.values(value).flatMap(collectStrings);
67
+ }
68
+
69
+ function normalizeText(value: unknown): string {
70
+ return collectStrings(value).join(' ').normalize('NFC');
71
+ }
72
+
73
+ function translatableContent(content: Record<string, unknown>) {
74
+ return TRANSLATABLE_KEYS.map((key) => content[key]);
75
+ }
76
+
77
+ function letterCount(text: string): number {
78
+ return text.match(LETTERS)?.length ?? 0;
79
+ }
80
+
81
+ function diacriticCount(text: string, locale: LocaleWithDiacritics): number {
82
+ return text.match(DIACRITIC_RULES[locale].characters)?.length ?? 0;
83
+ }
84
+
85
+ function diacriticsPerThousandLetters(text: string, locale: LocaleWithDiacritics): number {
86
+ const letters = letterCount(text);
87
+ if (letters === 0) return 0;
88
+ return diacriticCount(text, locale) / letters * 1000;
89
+ }
90
+
91
+ describe('Diacritics density validation', () => {
92
+ ALL_TOOLS.forEach((tool) => {
93
+ describe(`Tool: ${tool.entry.id}`, () => {
94
+ Object.keys(DIACRITIC_RULES).forEach((locale) => {
95
+ it(`${locale} keeps the expected accent and special-letter set`, async () => {
96
+ const typedLocale = locale as LocaleWithDiacritics;
97
+ const loader = tool.entry.i18n[typedLocale];
98
+ if (!loader) return;
99
+
100
+ const content = await loader();
101
+ const text = normalizeText(translatableContent(content as Record<string, unknown>));
102
+ const rule = DIACRITIC_RULES[typedLocale];
103
+ const letters = letterCount(text);
104
+ const matches = diacriticCount(text, typedLocale);
105
+ const density = diacriticsPerThousandLetters(text, typedLocale);
106
+
107
+ expect(
108
+ density,
109
+ [
110
+ `Possible spelling or encoding issue detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
111
+ `The text has ${matches} special characters (${density.toFixed(2)} per 1000 letters, ${letters} letters analyzed).`,
112
+ `This locale should include some of these characters: ${rule.expectedCharacters}.`,
113
+ 'If the count is 0 or near 0, accents, tildes, or special letters were probably stripped by encoding or normalization.',
114
+ ].join(' '),
115
+ ).toBeGreaterThanOrEqual(rule.minPerThousandLetters);
116
+
117
+ if (density < rule.minPerThousandLetters) {
118
+ const existing = failures.get(tool.entry.id) ?? [];
119
+ existing.push(typedLocale);
120
+ failures.set(tool.entry.id, existing);
121
+ }
122
+ });
123
+ });
124
+ });
125
+ });
126
+ });
127
+
128
+ afterAll(() => {
129
+ if (failures.size > 0) {
130
+ const sorted = [...failures.entries()].sort(([a], [b]) => a.localeCompare(b));
131
+ console.log('\n=== DIACRITICS DENSITY FAILURES (grouped by tool) ===');
132
+ let total = 0;
133
+ for (const [tool, locales] of sorted) {
134
+ locales.sort();
135
+ console.log(` ${tool}: ${locales.join(', ')}`);
136
+ total += locales.length;
137
+ }
138
+ console.log(` Total: ${total} failures across ${failures.size} tools\n`);
139
+ }
140
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const INVERTED_PUNCTUATION_LOCALES = {
5
+ es: {
6
+ language: 'Spanish',
7
+ questionStart: '¿',
8
+ questionEnd: '?',
9
+ exclamationStart: '¡',
10
+ exclamationEnd: '!',
11
+ },
12
+ } as const;
13
+
14
+ type InvertedPunctuationLocale = keyof typeof INVERTED_PUNCTUATION_LOCALES;
15
+
16
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
17
+
18
+ function collectStrings(value: unknown): string[] {
19
+ if (typeof value === 'string') return [value];
20
+ if (!value || typeof value !== 'object') return [];
21
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
22
+ return Object.values(value).flatMap(collectStrings);
23
+ }
24
+
25
+ function translatableStrings(content: Record<string, unknown>): string[] {
26
+ return TRANSLATABLE_KEYS.flatMap((key) => collectStrings(content[key]));
27
+ }
28
+
29
+ const SENTENCE_BOUNDARIES = ['.', ':', ';', '\n'];
30
+
31
+ function isNumberPeriod(text: string, index: number): boolean {
32
+ const prev = text[index - 1];
33
+ const next = text[index + 1];
34
+ return prev !== undefined && next !== undefined && /\d/.test(prev) && /\d/.test(next);
35
+ }
36
+
37
+ function sentenceStart(text: string, endIndex: number): string {
38
+ const beforeMark = text.slice(0, endIndex).trimEnd();
39
+
40
+ for (let i = beforeMark.length - 1; i >= 0; i--) {
41
+ if (SENTENCE_BOUNDARIES.includes(beforeMark[i])) {
42
+ if (beforeMark[i] === '.' && isNumberPeriod(beforeMark, i)) {
43
+ continue;
44
+ }
45
+ return beforeMark.slice(i + 1).trimStart();
46
+ }
47
+ }
48
+
49
+ return beforeMark.trimStart();
50
+ }
51
+
52
+ function findMissingInvertedMarks(
53
+ text: string,
54
+ startMark: string,
55
+ endMark: string,
56
+ ): string[] {
57
+ return [...text.matchAll(new RegExp(`\\${endMark}`, 'g'))]
58
+ .map((match) => sentenceStart(text, match.index ?? 0))
59
+ .filter((segment) => segment.length > 0 && !segment.includes(startMark))
60
+ .map((segment) => `${segment}${endMark}`);
61
+ }
62
+
63
+ describe('Inverted punctuation validation', () => {
64
+ ALL_TOOLS.forEach((tool) => {
65
+ describe(`Tool: ${tool.entry.id}`, () => {
66
+ Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
67
+ it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
68
+ const typedLocale = locale as InvertedPunctuationLocale;
69
+ const loader = tool.entry.i18n[typedLocale];
70
+ if (!loader) return;
71
+
72
+ const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
73
+ const content = await loader();
74
+ const strings = translatableStrings(content as Record<string, unknown>);
75
+ const missingQuestions = strings.flatMap((text) =>
76
+ findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
77
+ );
78
+ const missingExclamations = strings.flatMap((text) =>
79
+ findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd)
80
+ );
81
+ const failures = [...missingQuestions, ...missingExclamations];
82
+
83
+ expect(
84
+ failures,
85
+ [
86
+ `Missing opening punctuation marks in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
87
+ `Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
88
+ `Examples: ${failures.slice(0, 5).join(' | ')}`,
89
+ ].join(' '),
90
+ ).toHaveLength(0);
91
+ });
92
+ });
93
+ });
94
+ });
95
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ function getFiles(dir: string): string[] {
6
+ const results: string[] = [];
7
+ if (!fs.existsSync(dir)) {
8
+ return results;
9
+ }
10
+ const list = fs.readdirSync(dir);
11
+ for (const file of list) {
12
+ const fullPath = path.join(dir, file);
13
+ const stat = fs.statSync(fullPath);
14
+ if (stat && stat.isDirectory()) {
15
+ if (file !== 'tests' && file !== 'node_modules' && file !== '.astro') {
16
+ results.push(...getFiles(fullPath));
17
+ }
18
+ } else {
19
+ results.push(fullPath);
20
+ }
21
+ }
22
+ return results;
23
+ }
24
+
25
+ function isContentFile(filePath: string): boolean {
26
+ return /\\i18n\\/.test(filePath) || filePath.endsWith('bibliography.ts');
27
+ }
28
+
29
+ const srcDir = path.join(process.cwd(), 'src');
30
+ const scriptsDir = path.join(process.cwd(), 'scripts');
31
+ const filesToTest = [
32
+ ...getFiles(srcDir).filter(isContentFile),
33
+ ...getFiles(scriptsDir).filter(isContentFile),
34
+ ];
35
+
36
+ const aiTypographyGarbage = [
37
+ '\u2013',
38
+ '\u2014',
39
+ '\u2026',
40
+ '\u201C',
41
+ '\u201D',
42
+ '\u2018',
43
+ '\u2019',
44
+ '\u00AB',
45
+ '\u00BB',
46
+ '\u200B',
47
+ '\u201E',
48
+ ];
49
+
50
+ describe('Typography Garbage Character Validation', () => {
51
+ filesToTest.forEach((filePath) => {
52
+ const relativePath = path.relative(process.cwd(), filePath);
53
+ it(`should not contain typography garbage characters in ${relativePath}`, () => {
54
+ const content = fs.readFileSync(filePath, 'utf-8');
55
+ const hasAiPatterns = aiTypographyGarbage.some(char => content.includes(char));
56
+ expect(hasAiPatterns).toBe(false);
57
+ });
58
+
59
+ it(`should not contain space before colon in ${relativePath}`, () => {
60
+ const content = fs.readFileSync(filePath, 'utf-8');
61
+ const spaceBeforeColon = / : /.test(content);
62
+ expect(spaceBeforeColon).toBe(false);
63
+ });
64
+
65
+ it(`should not contain double hyphen in ${relativePath}`, () => {
66
+ const content = fs.readFileSync(filePath, 'utf-8');
67
+ expect(content).not.toContain('--');
68
+ });
69
+ });
70
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ type ScriptLocale = keyof typeof SCRIPT_RULES;
5
+
6
+ const SCRIPT_RULES = {
7
+ ja: {
8
+ language: 'Japanese',
9
+ scriptName: 'kana/kanji',
10
+ scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
11
+ minScriptRatio: 0.45,
12
+ },
13
+ ko: {
14
+ language: 'Korean',
15
+ scriptName: 'hangul',
16
+ scriptCharacters: /\p{Script=Hangul}/gu,
17
+ minScriptRatio: 0.55,
18
+ },
19
+ ru: {
20
+ language: 'Russian',
21
+ scriptName: 'cyrillic',
22
+ scriptCharacters: /\p{Script=Cyrillic}/gu,
23
+ minScriptRatio: 0.65,
24
+ },
25
+ zh: {
26
+ language: 'Chinese',
27
+ scriptName: 'han',
28
+ scriptCharacters: /\p{Script=Han}/gu,
29
+ minScriptRatio: 0.45,
30
+ },
31
+ } as const;
32
+
33
+ const LETTERS = /\p{L}/gu;
34
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
35
+
36
+ function collectStrings(value: unknown): string[] {
37
+ if (typeof value === 'string') return [value];
38
+ if (!value || typeof value !== 'object') return [];
39
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
40
+ return Object.values(value).flatMap(collectStrings);
41
+ }
42
+
43
+ function normalizeText(value: unknown): string {
44
+ return collectStrings(value).join(' ').normalize('NFC');
45
+ }
46
+
47
+ function translatableContent(content: Record<string, unknown>) {
48
+ return TRANSLATABLE_KEYS.map((key) => content[key]);
49
+ }
50
+
51
+ function letterCount(text: string): number {
52
+ return text.match(LETTERS)?.length ?? 0;
53
+ }
54
+
55
+ function scriptCount(text: string, locale: ScriptLocale): number {
56
+ return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
57
+ }
58
+
59
+ function scriptRatio(text: string, locale: ScriptLocale): number {
60
+ const letters = letterCount(text);
61
+ if (letters === 0) return 0;
62
+ return scriptCount(text, locale) / letters;
63
+ }
64
+
65
+ describe('Native script density validation', () => {
66
+ ALL_TOOLS.forEach((tool) => {
67
+ describe(`Tool: ${tool.entry.id}`, () => {
68
+ Object.keys(SCRIPT_RULES).forEach((locale) => {
69
+ it(`${locale} keeps most translated text in its native script`, async () => {
70
+ const typedLocale = locale as ScriptLocale;
71
+ const loader = tool.entry.i18n[typedLocale];
72
+ if (!loader) return;
73
+
74
+ const content = await loader();
75
+ const rule = SCRIPT_RULES[typedLocale];
76
+ const text = normalizeText(translatableContent(content as Record<string, unknown>));
77
+ const letters = letterCount(text);
78
+ const matches = scriptCount(text, typedLocale);
79
+ const ratio = scriptRatio(text, typedLocale);
80
+
81
+ expect(
82
+ ratio,
83
+ [
84
+ `Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
85
+ `The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
86
+ `Most translatable content should be written in ${rule.scriptName} script.`,
87
+ 'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
88
+ ].join(' '),
89
+ ).toBeGreaterThanOrEqual(rule.minScriptRatio);
90
+ });
91
+ });
92
+ });
93
+ });
94
+ });