@jjlmoya/utils-finance 1.19.0 → 1.20.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.
- package/package.json +1 -1
- package/src/category/i18n/fr.ts +9 -9
- package/src/category/i18n/ru.ts +4 -4
- package/src/layouts/PreviewLayout.astro +1 -0
- package/src/tests/diacritics_density.test.ts +140 -0
- package/src/tests/inverted_punctuation.test.ts +95 -0
- package/src/tests/no_en_dash.test.ts +70 -0
- package/src/tests/script_density.test.ts +94 -0
- package/src/tool/compoundInterest/i18n/de.ts +4 -4
- package/src/tool/compoundInterest/i18n/fr.ts +9 -9
- package/src/tool/compoundInterest/i18n/it.ts +18 -18
- package/src/tool/compoundInterest/i18n/pl.ts +2 -2
- package/src/tool/compoundInterest/i18n/ru.ts +7 -7
- package/src/tool/compoundInterest/i18n/tr.ts +7 -7
- package/src/tool/compoundInterest/i18n/zh.ts +4 -4
- package/src/tool/courtFeeCalculator/i18n/it.ts +10 -10
- package/src/tool/courtFeeCalculator/i18n/ru.ts +2 -2
- package/src/tool/courtFeeCalculator/i18n/tr.ts +6 -6
- package/src/tool/courtFeeCalculator/spanish-court-fees-calculator.css +47 -2
- package/src/tool/debtSnowball/bibliography.ts +2 -2
- package/src/tool/debtSnowball/i18n/it.ts +2 -2
- package/src/tool/debtSnowball/i18n/pl.ts +1 -1
- package/src/tool/debtSnowball/i18n/tr.ts +1 -1
- package/src/tool/debtSnowball/i18n/zh.ts +1 -1
- package/src/tool/financialFreedom/financial-freedom-calculator.css +89 -0
- package/src/tool/financialFreedom/i18n/en.ts +1 -1
- package/src/tool/financialFreedom/i18n/fr.ts +3 -3
- package/src/tool/financialFreedom/i18n/ru.ts +2 -2
- package/src/tool/financialFreedom/i18n/zh.ts +1 -1
- package/src/tool/fireCalculator/i18n/it.ts +14 -14
- package/src/tool/fireCalculator/i18n/ru.ts +6 -6
- package/src/tool/fireCalculator/i18n/tr.ts +6 -6
- package/src/tool/fireCalculator/i18n/zh.ts +3 -3
- package/src/tool/ibanBicSwiftConverter/i18n/fr.ts +1 -1
- package/src/tool/ibanBicSwiftConverter/i18n/it.ts +7 -7
- package/src/tool/ibanBicSwiftConverter/i18n/ru.ts +1 -1
- package/src/tool/ibanBicSwiftConverter/i18n/tr.ts +5 -5
- package/src/tool/ibanBicSwiftConverter/iban-to-bic-swift-converter.css +61 -3
- package/src/tool/inflation/i18n/de.ts +2 -2
- package/src/tool/inflation/i18n/fr.ts +1 -1
- package/src/tool/inflation/i18n/it.ts +32 -32
- package/src/tool/inflation/i18n/nl.ts +7 -7
- package/src/tool/inflation/i18n/pl.ts +2 -2
- package/src/tool/inflation/i18n/pt.ts +2 -2
- package/src/tool/inflation/i18n/ru.ts +6 -6
- package/src/tool/inflation/i18n/sv.ts +3 -3
- package/src/tool/inflation/i18n/tr.ts +14 -14
- package/src/tool/inflation/i18n/zh.ts +2 -2
- package/src/tool/lateInterest/i18n/de.ts +2 -2
- package/src/tool/lateInterest/i18n/es.ts +5 -5
- package/src/tool/lateInterest/i18n/it.ts +14 -14
- package/src/tool/lateInterest/i18n/nl.ts +1 -1
- package/src/tool/lateInterest/i18n/pl.ts +1 -1
- package/src/tool/lateInterest/i18n/pt.ts +1 -1
- package/src/tool/lateInterest/i18n/ru.ts +4 -4
- package/src/tool/lateInterest/i18n/sv.ts +3 -3
- package/src/tool/lateInterest/i18n/tr.ts +7 -7
- package/src/tool/lateInterest/i18n/zh.ts +1 -1
- package/src/tool/legalInterestRate/i18n/it.ts +15 -15
- package/src/tool/legalInterestRate/i18n/ru.ts +1 -1
- package/src/tool/legalInterestRate/i18n/tr.ts +8 -8
- package/src/tool/lotteryOptimizer/i18n/it.ts +7 -7
- package/src/tool/lotteryOptimizer/i18n/pl.ts +1 -1
- package/src/tool/lotteryOptimizer/i18n/ru.ts +4 -4
- package/src/tool/lotteryOptimizer/i18n/tr.ts +4 -4
- package/src/tool/lotteryOptimizer/i18n/zh.ts +3 -3
- package/src/tool/mortgage/i18n/de.ts +1 -1
- package/src/tool/mortgage/i18n/fr.ts +4 -4
- package/src/tool/mortgage/i18n/id.ts +1 -1
- package/src/tool/mortgage/i18n/it.ts +12 -12
- package/src/tool/mortgage/i18n/nl.ts +2 -2
- package/src/tool/mortgage/i18n/pl.ts +1 -1
- package/src/tool/mortgage/i18n/ru.ts +5 -5
- package/src/tool/mortgage/i18n/tr.ts +6 -6
- package/src/tool/mortgage/i18n/zh.ts +1 -1
- package/src/tool/mortgage/mortgage-calculator.css +58 -1
- package/src/tool/percentageCalculator/i18n/de.ts +4 -4
- package/src/tool/percentageCalculator/i18n/en.ts +1 -1
- package/src/tool/percentageCalculator/i18n/es.ts +2 -2
- package/src/tool/percentageCalculator/i18n/fr.ts +2 -2
- package/src/tool/percentageCalculator/i18n/it.ts +22 -22
- package/src/tool/percentageCalculator/i18n/pl.ts +4 -4
- package/src/tool/percentageCalculator/i18n/ru.ts +9 -9
- package/src/tool/percentageCalculator/i18n/tr.ts +18 -18
- package/src/tool/percentageCalculator/i18n/zh.ts +6 -6
- package/src/tool/profitability/i18n/fr.ts +2 -2
- package/src/tool/profitability/i18n/ru.ts +1 -1
- package/src/tool/profitability/i18n/zh.ts +2 -2
- package/src/tool/profitability/profitability-calculator.css +101 -0
- package/src/tool/rentIncreaseCalculator/i18n/it.ts +13 -13
- package/src/tool/rentIncreaseCalculator/i18n/tr.ts +5 -5
- package/src/tool/rentIncreaseCalculator/rent-increase-calculator.css +44 -2
package/package.json
CHANGED
package/src/category/i18n/fr.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const content: CategoryLocaleContent = {
|
|
|
8
8
|
seo: [
|
|
9
9
|
{
|
|
10
10
|
type: 'title',
|
|
11
|
-
text: "Ingénierie Financière Personnelle
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
114
|
+
text: 'Planification de la Retraite Anticipée: FIRE',
|
|
115
115
|
level: 2,
|
|
116
116
|
},
|
|
117
117
|
{
|
package/src/category/i18n/ru.ts
CHANGED
|
@@ -70,7 +70,7 @@ export const content: CategoryLocaleContent = {
|
|
|
70
70
|
{
|
|
71
71
|
type: 'tip',
|
|
72
72
|
title: 'Стратегический Совет по Сбережениям',
|
|
73
|
-
html: '<p><strong>Стратегия 2026:</strong> В условиях устойчивой инфляции хранение наличных
|
|
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: 'Риск нельзя устранить, но им можно управлять. Диверсификация
|
|
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: 'Будущее финансов
|
|
145
|
+
html: 'Будущее финансов - это <strong>самостоятельное хранение и техническое понимание</strong>. Доверять тому, что говорит банк, больше недостаточно. Эти инструменты дают способность проверять каждый цент, каждое начисление процентов и каждый договор, позволяя ориентироваться в финансовой системе с уверенностью, которую даёт математическое знание.',
|
|
146
146
|
},
|
|
147
147
|
{
|
|
148
148
|
type: 'stats',
|
|
@@ -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
|
+
});
|
|
@@ -17,10 +17,10 @@ const faqData = [
|
|
|
17
17
|
{
|
|
18
18
|
question: 'Was ist der Unterschied zum einfachen Zins?',
|
|
19
19
|
answer:
|
|
20
|
-
'Beim einfachen Zins werden die Zinsen nur auf das ursprüngliche Kapital berechnet. Beim Zinseszins werden die Zinsen reinvestiert, was bedeutet, dass
|
|
20
|
+
'Beim einfachen Zins werden die Zinsen nur auf das ursprüngliche Kapital berechnet. Beim Zinseszins werden die Zinsen reinvestiert, was bedeutet, dass "Ihre Zinsen weitere Zinsen generieren".',
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
|
-
question: 'Was ist die
|
|
23
|
+
question: 'Was ist die "72er-Regel"?',
|
|
24
24
|
answer:
|
|
25
25
|
'Es ist eine Faustformel zur Schätzung, wie lange es dauert, bis sich eine Investition verdoppelt: Teilen Sie 72 durch die jährliche Rendite. Beispiel: Bei 8 % pro Jahr verdoppeln Sie Ihr Geld in 9 Jahren.',
|
|
26
26
|
},
|
|
@@ -101,7 +101,7 @@ export const content: ToolLocaleContent<CompoundInterestUI> = {
|
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
type: 'paragraph',
|
|
104
|
-
html: 'Albert Einstein nannte ihn das
|
|
104
|
+
html: 'Albert Einstein nannte ihn das "achte Weltwunder". Der <strong>Zinseszins</strong> ist der mächtigste Mechanismus, um langfristig Reichtum zu generieren. Man muss kein Finanzexperte sein, um ihn zu nutzen: Man braucht nur Zeit, Geduld und investiertes Geld.',
|
|
105
105
|
},
|
|
106
106
|
{
|
|
107
107
|
type: 'title',
|
|
@@ -141,7 +141,7 @@ export const content: ToolLocaleContent<CompoundInterestUI> = {
|
|
|
141
141
|
},
|
|
142
142
|
{
|
|
143
143
|
type: 'paragraph',
|
|
144
|
-
html: 'Mathematisch gesehen generieren Ihre Gewinne neue Gewinne. In den ersten Jahren scheint es langsam zu sein, aber nach dem
|
|
144
|
+
html: 'Mathematisch gesehen generieren Ihre Gewinne neue Gewinne. In den ersten Jahren scheint es langsam zu sein, aber nach dem "Wendepunkt" schießt die Kurve vertikal nach oben. Hier entstehen die wahren Vermögen.',
|
|
145
145
|
},
|
|
146
146
|
{
|
|
147
147
|
type: 'title',
|
|
@@ -22,7 +22,7 @@ const faqData = [
|
|
|
22
22
|
{
|
|
23
23
|
question: "Qu'est-ce que la 'Règle des 72' ?",
|
|
24
24
|
answer:
|
|
25
|
-
"C'est une formule rapide pour estimer combien de temps il faudra pour doubler un investissement
|
|
25
|
+
"C'est une formule rapide pour estimer combien de temps il faudra pour doubler un investissement: divisez 72 par le taux de rendement annuel. Par exemple, à 8% par an, vous doublez votre argent en 9 ans.",
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
question: 'Pourquoi est-il important de commencer à investir jeune ?',
|
|
@@ -96,21 +96,21 @@ export const content: ToolLocaleContent<CompoundInterestUI> = {
|
|
|
96
96
|
seo: [
|
|
97
97
|
{
|
|
98
98
|
type: 'title',
|
|
99
|
-
text: 'La Magie de l\'Intérêt
|
|
99
|
+
text: 'La Magie de l\'Intérêt Composé: Construisez Votre Richesse Exponentiellement',
|
|
100
100
|
level: 2,
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
type: 'paragraph',
|
|
104
|
-
html: 'Albert Einstein l\'a appelée la "huitième merveille du monde". L\'<strong>intérêt composé</strong> est le mécanisme le plus puissant pour construire une richesse à long terme. Vous n\'avez pas besoin d\'être un expert en finance pour l\'utiliser
|
|
104
|
+
html: 'Albert Einstein l\'a appelée la "huitième merveille du monde". L\'<strong>intérêt composé</strong> est le mécanisme le plus puissant pour construire une richesse à long terme. Vous n\'avez pas besoin d\'être un expert en finance pour l\'utiliser: vous avez juste besoin de temps, de patience et d\'argent investi.',
|
|
105
105
|
},
|
|
106
106
|
{
|
|
107
107
|
type: 'title',
|
|
108
|
-
text: 'Intérêt Simple vs
|
|
108
|
+
text: 'Intérêt Simple vs Composé: La Métaphore de la Boule de Neige',
|
|
109
109
|
level: 3,
|
|
110
110
|
},
|
|
111
111
|
{
|
|
112
112
|
type: 'paragraph',
|
|
113
|
-
html: 'Imaginez une boule de neige au sommet d\'une colline. L\'<strong>intérêt simple</strong> est comme la faire rouler vers le bas et devoir ajouter manuellement de la neige tous les mètres pour qu\'elle grandisse. L\'<strong>intérêt composé</strong> est comme la laisser rouler seule
|
|
113
|
+
html: 'Imaginez une boule de neige au sommet d\'une colline. L\'<strong>intérêt simple</strong> est comme la faire rouler vers le bas et devoir ajouter manuellement de la neige tous les mètres pour qu\'elle grandisse. L\'<strong>intérêt composé</strong> est comme la laisser rouler seule: elle ramasse naturellement de la neige, et plus elle devient grosse, plus elle a de surface pour ramasser <em>encore plus</em> de neige à chaque rotation.',
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
type: 'comparative',
|
|
@@ -120,7 +120,7 @@ export const content: ToolLocaleContent<CompoundInterestUI> = {
|
|
|
120
120
|
title: 'Intérêt Simple',
|
|
121
121
|
description: 'Les intérêts sont toujours calculés sur le capital original.',
|
|
122
122
|
points: [
|
|
123
|
-
'Formule
|
|
123
|
+
'Formule: Capital × Taux × Temps',
|
|
124
124
|
'Croissance linéaire et prévisible',
|
|
125
125
|
'Utilisé dans les prêts à court terme',
|
|
126
126
|
'Pas de réinvestissement des bénéfices',
|
|
@@ -131,7 +131,7 @@ export const content: ToolLocaleContent<CompoundInterestUI> = {
|
|
|
131
131
|
description: 'Les intérêts s\'ajoutent au capital et génèrent de nouveaux intérêts.',
|
|
132
132
|
highlight: true,
|
|
133
133
|
points: [
|
|
134
|
-
'Formule
|
|
134
|
+
'Formule: Capital × (1 + Taux)^Temps',
|
|
135
135
|
'Croissance exponentielle accélérée',
|
|
136
136
|
'Fondation de l\'investissement à long terme',
|
|
137
137
|
'Vos bénéfices génèrent plus de bénéfices',
|
|
@@ -155,7 +155,7 @@ export const content: ToolLocaleContent<CompoundInterestUI> = {
|
|
|
155
155
|
{
|
|
156
156
|
type: 'tip',
|
|
157
157
|
title: 'La Règle des 72',
|
|
158
|
-
html: '<p>Divisez 72 par votre rendement annuel pour découvrir en combien d\'années vous allez <strong>doubler votre argent</strong>.</p><p><em>Exemple
|
|
158
|
+
html: '<p>Divisez 72 par votre rendement annuel pour découvrir en combien d\'années vous allez <strong>doubler votre argent</strong>.</p><p><em>Exemple: À 8%, vous doublez tous les 9 ans (72/8 = 9).</em></p><p>Cette formule magique fonctionne pour n\'importe quel taux de rendement et vous aide à estimer rapidement la croissance de vos investissements.</p>',
|
|
159
159
|
},
|
|
160
160
|
{
|
|
161
161
|
type: 'tip',
|
|
@@ -164,7 +164,7 @@ export const content: ToolLocaleContent<CompoundInterestUI> = {
|
|
|
164
164
|
},
|
|
165
165
|
{
|
|
166
166
|
type: 'title',
|
|
167
|
-
text: 'Fréquence de Capitalisation
|
|
167
|
+
text: 'Fréquence de Capitalisation: Est-ce Important?',
|
|
168
168
|
level: 3,
|
|
169
169
|
},
|
|
170
170
|
{
|