@jjlmoya/utils-finance 1.30.0 → 1.32.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 (215) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/en.ts +1 -1
  3. package/src/category/i18n/es.ts +4 -4
  4. package/src/category/i18n/fr.ts +159 -159
  5. package/src/category/i18n/pl.ts +1 -1
  6. package/src/category/i18n/ru.ts +158 -158
  7. package/src/category/index.ts +39 -38
  8. package/src/category/seo.astro +1 -1
  9. package/src/components/PreviewNavSidebar.astro +1 -1
  10. package/src/components/PreviewToolbar.astro +1 -1
  11. package/src/data.ts +1 -1
  12. package/src/entries.ts +4 -1
  13. package/src/env.d.ts +1 -1
  14. package/src/index.ts +1 -0
  15. package/src/layouts/PreviewLayout.astro +113 -113
  16. package/src/pages/index.astro +1 -1
  17. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  18. package/src/tests/category_seo_quality.test.ts +76 -0
  19. package/src/tests/faq_count.test.ts +1 -1
  20. package/src/tests/locale_completeness.test.ts +2 -2
  21. package/src/tests/mocks/astro_mock.js +1 -1
  22. package/src/tests/no_h1_in_components.test.ts +1 -1
  23. package/src/tests/script_density.test.ts +94 -94
  24. package/src/tests/seo_length.test.ts +23 -23
  25. package/src/tests/seo_translation_completeness.test.ts +69 -0
  26. package/src/tests/seo_wellformed_export.test.ts +65 -0
  27. package/src/tests/tool_validation.test.ts +17 -17
  28. package/src/tool/compoundInterest/compound-interest-calculator.css +236 -236
  29. package/src/tool/compoundInterest/entry.ts +32 -32
  30. package/src/tool/compoundInterest/i18n/de.ts +195 -195
  31. package/src/tool/compoundInterest/i18n/fr.ts +195 -195
  32. package/src/tool/compoundInterest/i18n/it.ts +195 -195
  33. package/src/tool/compoundInterest/i18n/pl.ts +194 -194
  34. package/src/tool/compoundInterest/i18n/ru.ts +195 -195
  35. package/src/tool/compoundInterest/i18n/tr.ts +195 -195
  36. package/src/tool/compoundInterest/i18n/zh.ts +189 -189
  37. package/src/tool/compoundInterest/seo.astro +15 -15
  38. package/src/tool/courtFeeCalculator/i18n/it.ts +158 -158
  39. package/src/tool/courtFeeCalculator/i18n/ru.ts +158 -158
  40. package/src/tool/courtFeeCalculator/i18n/tr.ts +158 -158
  41. package/src/tool/courtFeeCalculator/seo.astro +15 -15
  42. package/src/tool/courtFeeCalculator/spanish-court-fees-calculator.css +197 -197
  43. package/src/tool/debtSnowball/bibliography.ts +12 -12
  44. package/src/tool/debtSnowball/i18n/de.ts +16 -16
  45. package/src/tool/debtSnowball/i18n/id.ts +16 -16
  46. package/src/tool/debtSnowball/i18n/it.ts +168 -168
  47. package/src/tool/debtSnowball/i18n/nl.ts +16 -16
  48. package/src/tool/debtSnowball/i18n/pl.ts +168 -168
  49. package/src/tool/debtSnowball/i18n/pt.ts +1 -1
  50. package/src/tool/debtSnowball/i18n/ru.ts +1 -1
  51. package/src/tool/debtSnowball/i18n/sv.ts +22 -29
  52. package/src/tool/debtSnowball/i18n/tr.ts +168 -168
  53. package/src/tool/debtSnowball/i18n/zh.ts +168 -168
  54. package/src/tool/debtSnowball/seo.astro +15 -15
  55. package/src/tool/dividendYield/bibliography.astro +14 -0
  56. package/src/tool/dividendYield/bibliography.ts +12 -0
  57. package/src/tool/dividendYield/component.astro +83 -0
  58. package/src/tool/dividendYield/controller.ts +85 -0
  59. package/src/tool/dividendYield/dividend-yield-calculator.css +352 -0
  60. package/src/tool/dividendYield/dom-views.ts +58 -0
  61. package/src/tool/dividendYield/entry.ts +29 -0
  62. package/src/tool/dividendYield/evaluator.ts +16 -0
  63. package/src/tool/dividendYield/i18n/de.ts +53 -0
  64. package/src/tool/dividendYield/i18n/en.ts +138 -0
  65. package/src/tool/dividendYield/i18n/es.ts +49 -0
  66. package/src/tool/dividendYield/i18n/fr.ts +49 -0
  67. package/src/tool/dividendYield/i18n/id.ts +49 -0
  68. package/src/tool/dividendYield/i18n/it.ts +49 -0
  69. package/src/tool/dividendYield/i18n/ja.ts +49 -0
  70. package/src/tool/dividendYield/i18n/ko.ts +25 -0
  71. package/src/tool/dividendYield/i18n/nl.ts +25 -0
  72. package/src/tool/dividendYield/i18n/pl.ts +25 -0
  73. package/src/tool/dividendYield/i18n/pt.ts +25 -0
  74. package/src/tool/dividendYield/i18n/ru.ts +25 -0
  75. package/src/tool/dividendYield/i18n/sv.ts +25 -0
  76. package/src/tool/dividendYield/i18n/tr.ts +25 -0
  77. package/src/tool/dividendYield/i18n/zh.ts +25 -0
  78. package/src/tool/dividendYield/index.ts +11 -0
  79. package/src/tool/dividendYield/logic.test.ts +73 -0
  80. package/src/tool/dividendYield/logic.ts +55 -0
  81. package/src/tool/dividendYield/seo.astro +14 -0
  82. package/src/tool/dividendYield/storage.ts +24 -0
  83. package/src/tool/dividendYield/ui.ts +33 -0
  84. package/src/tool/financialFreedom/financial-freedom-calculator.css +311 -311
  85. package/src/tool/financialFreedom/i18n/en.ts +182 -182
  86. package/src/tool/financialFreedom/i18n/fr.ts +128 -128
  87. package/src/tool/financialFreedom/i18n/id.ts +24 -7
  88. package/src/tool/financialFreedom/i18n/it.ts +24 -7
  89. package/src/tool/financialFreedom/i18n/ja.ts +1 -1
  90. package/src/tool/financialFreedom/i18n/nl.ts +24 -7
  91. package/src/tool/financialFreedom/i18n/pl.ts +24 -7
  92. package/src/tool/financialFreedom/i18n/pt.ts +24 -7
  93. package/src/tool/financialFreedom/i18n/ru.ts +128 -128
  94. package/src/tool/financialFreedom/i18n/sv.ts +1 -1
  95. package/src/tool/financialFreedom/i18n/tr.ts +2 -2
  96. package/src/tool/financialFreedom/i18n/zh.ts +128 -128
  97. package/src/tool/fireCalculator/i18n/it.ts +241 -241
  98. package/src/tool/fireCalculator/i18n/ru.ts +241 -241
  99. package/src/tool/fireCalculator/i18n/tr.ts +241 -241
  100. package/src/tool/fireCalculator/i18n/zh.ts +241 -241
  101. package/src/tool/fireCalculator/seo.astro +15 -15
  102. package/src/tool/ibanBicSwiftConverter/i18n/fr.ts +151 -151
  103. package/src/tool/ibanBicSwiftConverter/i18n/it.ts +118 -118
  104. package/src/tool/ibanBicSwiftConverter/i18n/ru.ts +118 -118
  105. package/src/tool/ibanBicSwiftConverter/i18n/tr.ts +118 -118
  106. package/src/tool/ibanBicSwiftConverter/iban-to-bic-swift-converter.css +366 -366
  107. package/src/tool/ibanBicSwiftConverter/seo.astro +15 -15
  108. package/src/tool/inflation/bibliography.ts +20 -20
  109. package/src/tool/inflation/entry.ts +32 -32
  110. package/src/tool/inflation/i18n/de.ts +284 -284
  111. package/src/tool/inflation/i18n/fr.ts +284 -284
  112. package/src/tool/inflation/i18n/it.ts +284 -284
  113. package/src/tool/inflation/i18n/nl.ts +284 -284
  114. package/src/tool/inflation/i18n/pl.ts +284 -284
  115. package/src/tool/inflation/i18n/pt.ts +284 -284
  116. package/src/tool/inflation/i18n/ru.ts +284 -284
  117. package/src/tool/inflation/i18n/sv.ts +284 -284
  118. package/src/tool/inflation/i18n/tr.ts +284 -284
  119. package/src/tool/inflation/i18n/zh.ts +284 -284
  120. package/src/tool/inflation/seo.astro +15 -15
  121. package/src/tool/lateInterest/bibliography.ts +16 -16
  122. package/src/tool/lateInterest/entry.ts +32 -32
  123. package/src/tool/lateInterest/i18n/de.ts +213 -213
  124. package/src/tool/lateInterest/i18n/es.ts +213 -213
  125. package/src/tool/lateInterest/i18n/it.ts +213 -213
  126. package/src/tool/lateInterest/i18n/nl.ts +213 -213
  127. package/src/tool/lateInterest/i18n/pl.ts +213 -213
  128. package/src/tool/lateInterest/i18n/pt.ts +213 -213
  129. package/src/tool/lateInterest/i18n/ru.ts +213 -213
  130. package/src/tool/lateInterest/i18n/sv.ts +213 -213
  131. package/src/tool/lateInterest/i18n/tr.ts +213 -213
  132. package/src/tool/lateInterest/i18n/zh.ts +213 -213
  133. package/src/tool/lateInterest/seo.astro +15 -15
  134. package/src/tool/legalInterestRate/i18n/it.ts +129 -129
  135. package/src/tool/legalInterestRate/i18n/ru.ts +129 -129
  136. package/src/tool/legalInterestRate/i18n/tr.ts +128 -121
  137. package/src/tool/legalInterestRate/i18n/zh.ts +11 -4
  138. package/src/tool/legalInterestRate/seo.astro +15 -15
  139. package/src/tool/lotteryOptimizer/i18n/it.ts +163 -163
  140. package/src/tool/lotteryOptimizer/i18n/pl.ts +163 -163
  141. package/src/tool/lotteryOptimizer/i18n/ru.ts +163 -163
  142. package/src/tool/lotteryOptimizer/i18n/tr.ts +163 -163
  143. package/src/tool/lotteryOptimizer/i18n/zh.ts +162 -155
  144. package/src/tool/lotteryOptimizer/seo.astro +15 -15
  145. package/src/tool/mortgage/bibliography.ts +20 -20
  146. package/src/tool/mortgage/entry.ts +32 -32
  147. package/src/tool/mortgage/i18n/de.ts +210 -210
  148. package/src/tool/mortgage/i18n/es.ts +210 -210
  149. package/src/tool/mortgage/i18n/fr.ts +210 -210
  150. package/src/tool/mortgage/i18n/id.ts +210 -210
  151. package/src/tool/mortgage/i18n/it.ts +210 -210
  152. package/src/tool/mortgage/i18n/nl.ts +210 -210
  153. package/src/tool/mortgage/i18n/pl.ts +210 -210
  154. package/src/tool/mortgage/i18n/ru.ts +210 -210
  155. package/src/tool/mortgage/i18n/tr.ts +210 -210
  156. package/src/tool/mortgage/i18n/zh.ts +210 -210
  157. package/src/tool/mortgage/mortgage-calculator.css +595 -595
  158. package/src/tool/mortgage/seo.astro +15 -15
  159. package/src/tool/percentageCalculator/bibliography.ts +16 -16
  160. package/src/tool/percentageCalculator/entry.ts +32 -32
  161. package/src/tool/percentageCalculator/i18n/de.ts +220 -220
  162. package/src/tool/percentageCalculator/i18n/en.ts +220 -220
  163. package/src/tool/percentageCalculator/i18n/es.ts +220 -220
  164. package/src/tool/percentageCalculator/i18n/fr.ts +220 -220
  165. package/src/tool/percentageCalculator/i18n/it.ts +220 -220
  166. package/src/tool/percentageCalculator/i18n/pl.ts +220 -220
  167. package/src/tool/percentageCalculator/i18n/ru.ts +220 -220
  168. package/src/tool/percentageCalculator/i18n/tr.ts +220 -220
  169. package/src/tool/percentageCalculator/i18n/zh.ts +220 -220
  170. package/src/tool/percentageCalculator/seo.astro +15 -15
  171. package/src/tool/profitability/i18n/de.ts +24 -7
  172. package/src/tool/profitability/i18n/fr.ts +121 -104
  173. package/src/tool/profitability/i18n/id.ts +2 -2
  174. package/src/tool/profitability/i18n/it.ts +24 -7
  175. package/src/tool/profitability/i18n/ja.ts +1 -1
  176. package/src/tool/profitability/i18n/nl.ts +24 -7
  177. package/src/tool/profitability/i18n/pl.ts +1 -1
  178. package/src/tool/profitability/i18n/pt.ts +24 -7
  179. package/src/tool/profitability/i18n/ru.ts +118 -118
  180. package/src/tool/profitability/i18n/sv.ts +1 -1
  181. package/src/tool/profitability/i18n/tr.ts +1 -1
  182. package/src/tool/profitability/i18n/zh.ts +118 -118
  183. package/src/tool/profitability/profitability-calculator.css +229 -229
  184. package/src/tool/rentIncreaseCalculator/i18n/it.ts +125 -125
  185. package/src/tool/rentIncreaseCalculator/i18n/tr.ts +125 -125
  186. package/src/tool/rentIncreaseCalculator/rent-increase-calculator.css +326 -326
  187. package/src/tool/rentIncreaseCalculator/seo.astro +15 -15
  188. package/src/tool/revolvingCardCalculator/i18n/de.ts +38 -20
  189. package/src/tool/revolvingCardCalculator/i18n/es.ts +19 -18
  190. package/src/tool/revolvingCardCalculator/i18n/fr.ts +32 -28
  191. package/src/tool/revolvingCardCalculator/i18n/id.ts +18 -18
  192. package/src/tool/revolvingCardCalculator/i18n/it.ts +18 -18
  193. package/src/tool/revolvingCardCalculator/i18n/ja.ts +2 -2
  194. package/src/tool/revolvingCardCalculator/i18n/ko.ts +2 -2
  195. package/src/tool/revolvingCardCalculator/i18n/nl.ts +22 -19
  196. package/src/tool/revolvingCardCalculator/i18n/pl.ts +2 -2
  197. package/src/tool/revolvingCardCalculator/i18n/pt.ts +2 -2
  198. package/src/tool/revolvingCardCalculator/i18n/ru.ts +2 -2
  199. package/src/tool/revolvingCardCalculator/i18n/sv.ts +2 -2
  200. package/src/tool/revolvingCardCalculator/i18n/tr.ts +2 -2
  201. package/src/tool/revolvingCardCalculator/i18n/zh.ts +6 -6
  202. package/src/tool/salaryEmployerCost/i18n/de.ts +10 -10
  203. package/src/tool/salaryEmployerCost/i18n/fr.ts +9 -9
  204. package/src/tool/salaryEmployerCost/i18n/id.ts +9 -9
  205. package/src/tool/salaryEmployerCost/i18n/it.ts +9 -9
  206. package/src/tool/salaryEmployerCost/i18n/ja.ts +1 -1
  207. package/src/tool/salaryEmployerCost/i18n/ko.ts +1 -1
  208. package/src/tool/salaryEmployerCost/i18n/nl.ts +2 -2
  209. package/src/tool/salaryEmployerCost/i18n/pl.ts +2 -2
  210. package/src/tool/salaryEmployerCost/i18n/pt.ts +2 -2
  211. package/src/tool/salaryEmployerCost/i18n/ru.ts +2 -2
  212. package/src/tool/salaryEmployerCost/i18n/sv.ts +2 -2
  213. package/src/tool/salaryEmployerCost/i18n/tr.ts +2 -2
  214. package/src/tool/salaryEmployerCost/i18n/zh.ts +2 -2
  215. package/src/tools.ts +2 -1
@@ -1,118 +1,118 @@
1
- ---
2
- import "@jjlmoya/utils-shared/theme.css";
3
- import PreviewToolbar from "../components/PreviewToolbar.astro";
4
- import type { KnownLocale } from "../types";
5
-
6
- interface Props {
7
- title: string;
8
- currentLocale?: KnownLocale;
9
- localeUrls?: Partial<Record<KnownLocale, string>>;
10
- hasSidebar?: boolean;
11
- }
12
-
13
- const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Astro.props;
14
- ---
15
-
16
- <!doctype html>
17
- <html lang={currentLocale}>
18
- <head>
19
- <meta charset="UTF-8" />
20
- <meta name="viewport" content="width=device-width" />
21
- <title>{title} · preview</title>
22
- <link rel="preconnect" href="https://fonts.googleapis.com" />
23
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
24
- <link
25
- href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
26
- rel="stylesheet"
27
- />
28
-
29
- <script is:inline>
30
- (function () {
31
- const saved = localStorage.getItem("theme") || "theme-dark";
32
- document.documentElement.classList.add(saved);
33
- })();
34
- </script>
35
- <slot name="head" />
36
- </head>
37
- <body>
38
- <PreviewToolbar currentLocale={currentLocale} localeUrls={localeUrls} />
39
- <div class:list={["page-wrapper", { "with-sidebar": hasSidebar }]}>
40
- {
41
- hasSidebar && (
42
- <aside class="sidebar-area">
43
- <slot name="sidebar" />
44
- </aside>
45
- )
46
- }
47
- <main>
48
- <slot />
49
- </main>
50
- </div>
51
- </body>
52
- </html>
53
-
54
- <style is:global>
55
- :root {
56
- --accent: #f43f5e;
57
- --primary-base: #9f1239;
58
- --cyan: #06b6d4;
59
- }
60
-
61
- .theme-dark,
62
- .theme-light {
63
- --text-main: var(--text-base);
64
- --border-color: var(--border-base);
65
- }
66
-
67
- *,
68
- *::before,
69
- *::after {
70
- box-sizing: border-box;
71
- }
72
-
73
- body {
74
- background-color: var(--bg-page);
75
- color: var(--text-base);
76
- margin: 0;
77
- min-height: 100vh;
1
+ ---
2
+ import "@jjlmoya/utils-shared/theme.css";
3
+ import PreviewToolbar from "../components/PreviewToolbar.astro";
4
+ import type { KnownLocale } from "../types";
5
+
6
+ interface Props {
7
+ title: string;
8
+ currentLocale?: KnownLocale;
9
+ localeUrls?: Partial<Record<KnownLocale, string>>;
10
+ hasSidebar?: boolean;
11
+ }
12
+
13
+ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Astro.props;
14
+ ---
15
+
16
+ <!doctype html>
17
+ <html lang={currentLocale}>
18
+ <head>
19
+ <meta charset="UTF-8" />
20
+ <meta name="viewport" content="width=device-width" />
21
+ <title>{title} · preview</title>
22
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
23
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
24
+ <link
25
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
26
+ rel="stylesheet"
27
+ />
28
+
29
+ <script is:inline>
30
+ (function () {
31
+ const saved = localStorage.getItem("theme") || "theme-dark";
32
+ document.documentElement.classList.add(saved);
33
+ })();
34
+ </script>
35
+ <slot name="head" />
36
+ </head>
37
+ <body>
38
+ <PreviewToolbar currentLocale={currentLocale} localeUrls={localeUrls} />
39
+ <div class:list={["page-wrapper", { "with-sidebar": hasSidebar }]}>
40
+ {
41
+ hasSidebar && (
42
+ <aside class="sidebar-area">
43
+ <slot name="sidebar" />
44
+ </aside>
45
+ )
46
+ }
47
+ <main>
48
+ <slot />
49
+ </main>
50
+ </div>
51
+ </body>
52
+ </html>
53
+
54
+ <style is:global>
55
+ :root {
56
+ --accent: #f43f5e;
57
+ --primary-base: #9f1239;
58
+ --cyan: #06b6d4;
59
+ }
60
+
61
+ .theme-dark,
62
+ .theme-light {
63
+ --text-main: var(--text-base);
64
+ --border-color: var(--border-base);
65
+ }
66
+
67
+ *,
68
+ *::before,
69
+ *::after {
70
+ box-sizing: border-box;
71
+ }
72
+
73
+ body {
74
+ background-color: var(--bg-page);
75
+ color: var(--text-base);
76
+ margin: 0;
77
+ min-height: 100vh;
78
78
  transition:
79
79
  background-color 0.3s ease,
80
80
  color 0.3s ease;
81
81
  font-family: Inter, sans-serif;
82
82
  }
83
-
84
- main {
85
- padding: 0 2rem;
86
- }
87
-
88
- .page-wrapper {
89
- display: flex;
90
- flex-direction: column;
91
- }
92
-
93
- .page-wrapper.with-sidebar {
94
- display: grid;
95
- grid-template-columns: 240px 1fr;
96
- min-height: 100vh;
97
- }
98
-
99
- .sidebar-area {
100
- position: sticky;
101
- top: 0;
102
- height: 100vh;
103
- overflow-y: auto;
104
- border-right: 1px solid var(--border-color);
105
- background: var(--bg-page);
106
- }
107
-
108
- @media (max-width: 768px) {
109
- .page-wrapper.with-sidebar {
110
- grid-template-columns: 1fr;
111
- }
112
-
113
- .sidebar-area {
114
- display: none;
115
- }
116
- }
117
- </style>
118
-
83
+
84
+ main {
85
+ padding: 0 2rem;
86
+ }
87
+
88
+ .page-wrapper {
89
+ display: flex;
90
+ flex-direction: column;
91
+ }
92
+
93
+ .page-wrapper.with-sidebar {
94
+ display: grid;
95
+ grid-template-columns: 240px 1fr;
96
+ min-height: 100vh;
97
+ }
98
+
99
+ .sidebar-area {
100
+ position: sticky;
101
+ top: 0;
102
+ height: 100vh;
103
+ overflow-y: auto;
104
+ border-right: 1px solid var(--border-color);
105
+ background: var(--bg-page);
106
+ }
107
+
108
+ @media (max-width: 768px) {
109
+ .page-wrapper.with-sidebar {
110
+ grid-template-columns: 1fr;
111
+ }
112
+
113
+ .sidebar-area {
114
+ display: none;
115
+ }
116
+ }
117
+ </style>
118
+
@@ -1,4 +1,4 @@
1
1
  ---
2
2
  ---
3
3
  <meta http-equiv="refresh" content="0;url=/es" />
4
-
4
+
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ function findBibliographyAstroFiles(dir: string): string[] {
6
+ const files: string[] = [];
7
+ if (!existsSync(dir)) return files;
8
+ const entries = readdirSync(dir, { withFileTypes: true });
9
+
10
+ for (const entry of entries) {
11
+ const fullPath = join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ files.push(...findBibliographyAstroFiles(fullPath));
14
+ } else if (entry.isFile() && entry.name === 'bibliography.astro') {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+
19
+ return files;
20
+ }
21
+
22
+ const toolDir = join(process.cwd(), 'src', 'tool');
23
+ const bibliographyFiles = findBibliographyAstroFiles(toolDir);
24
+
25
+ describe('Bibliography Component Wellformed Export', () => {
26
+ it('found tool bibliography.astro components', () => {
27
+ expect(bibliographyFiles.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ bibliographyFiles.forEach((file) => {
31
+ const relativePath = relative(process.cwd(), file);
32
+
33
+ it(`${relativePath} should use SharedBibliography from @jjlmoya/utils-shared`, () => {
34
+ const content = readFileSync(file, 'utf-8');
35
+
36
+ const usesSharedComponent =
37
+ content.includes('@jjlmoya/utils-shared') &&
38
+ (content.includes('Bibliography') || content.includes('SharedBibliography'));
39
+
40
+ expect(
41
+ usesSharedComponent,
42
+ `File "${relativePath}" does not use the standard Bibliography component from @jjlmoya/utils-shared, resulting in unstyled or raw bibliography output.`,
43
+ ).toBe(true);
44
+ });
45
+ });
46
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CategoryLocaleContent } from '../types';
3
+
4
+ const EXPECTED_LOCALES = [
5
+ 'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh',
6
+ ] as const;
7
+ const COMPACT_LOCALES = new Set(['ja', 'ko', 'zh']);
8
+
9
+ const localeModules = import.meta.glob('../category/i18n/*.ts', { eager: true }) as Record<
10
+ string,
11
+ { content: CategoryLocaleContent }
12
+ >;
13
+
14
+ function textFrom(value: unknown): string {
15
+ if (typeof value === 'string') return value.replace(/<[^>]*>/g, ' ');
16
+ if (Array.isArray(value)) return value.map(textFrom).join(' ');
17
+ if (value && typeof value === 'object') return Object.values(value).map(textFrom).join(' ');
18
+ return '';
19
+ }
20
+
21
+ const STRONG_CLAIM = /\b(?:garantiz\w*|guarante\w*|validat(?:ed)|valid(?:ated|ado|ada|ados|adas|ée|ées)|actualizad\w*)\b/iu;
22
+
23
+ function getContents(): Map<string, CategoryLocaleContent> {
24
+ return new Map(
25
+ Object.entries(localeModules).map(([file, module]) => [file.match(/\/([a-z]{2})\.ts$/u)?.[1] ?? file, module.content]),
26
+ );
27
+ }
28
+
29
+ function validateContent(locale: string, content: CategoryLocaleContent): string[] {
30
+ const failures: string[] = [];
31
+ const check = (condition: boolean, message: string) => { if (!condition) failures.push(`${locale}: ${message}`); };
32
+ const minimumTitleLength = COMPACT_LOCALES.has(locale) ? 4 : 12;
33
+ const minimumDescriptionLength = COMPACT_LOCALES.has(locale) ? 20 : 60;
34
+ check(content.title.trim().length >= minimumTitleLength && content.title.trim().length <= 70, 'title must use a useful 4/12–70 character length');
35
+ check(content.description.trim().length >= minimumDescriptionLength && content.description.trim().length <= 180, 'description must use a useful 20/60–180 character length');
36
+ check(content.seo.length >= 2, 'SEO needs at least two sections');
37
+
38
+ const seoText = textFrom(content.seo).replace(/\s+/g, ' ').trim();
39
+ const minimumSeoLength = COMPACT_LOCALES.has(locale) ? 120 : 240;
40
+ check(seoText.length >= minimumSeoLength, 'SEO copy must contain the minimum visible copy for its script');
41
+ check(content.seo.filter((section) => section.type === 'title').length >= 1, 'SEO needs a section heading');
42
+ check(!/[�]/u.test(seoText), 'SEO contains replacement characters');
43
+ check(!/ {2,}/u.test(seoText), 'SEO contains duplicated whitespace');
44
+ check(!STRONG_CLAIM.test(seoText), 'SEO contains an unsupported strong claim');
45
+
46
+ check(content.slug.trim().length > 0 && /^[a-z0-9-]+$/u.test(content.slug), 'slug must be a non-empty URL-safe value');
47
+ return failures;
48
+ }
49
+
50
+ describe('Category SEO quality contract', () => {
51
+ it('has one content file for every configured locale', () => {
52
+ const contents = getContents();
53
+ expect([...contents.keys()].sort()).toEqual([...EXPECTED_LOCALES].sort());
54
+ });
55
+
56
+ it('keeps metadata, SEO structure and copy quality above the minimum in every locale', () => {
57
+ const contents = getContents();
58
+ const english = contents.get('en');
59
+ expect(english, 'English category content is required as the structural reference').toBeDefined();
60
+
61
+ const failures = EXPECTED_LOCALES.flatMap((locale) => {
62
+ const content = contents.get(locale);
63
+ return content ? validateContent(locale, content) : [];
64
+ });
65
+
66
+ const englishShape = english?.seo.map((section) => section.type);
67
+ for (const locale of EXPECTED_LOCALES) {
68
+ const content = contents.get(locale);
69
+ if (content && JSON.stringify(content.seo.map((section) => section.type)) !== JSON.stringify(englishShape)) {
70
+ failures.push(`${locale}: SEO section types must match English`);
71
+ }
72
+ }
73
+
74
+ expect(failures, 'category SEO quality failures').toEqual([]);
75
+ }, 30000);
76
+ });
@@ -16,4 +16,4 @@ describe('FAQ Content Validation', () => {
16
16
  expect(TOOLS.length).toBe(0);
17
17
  });
18
18
  });
19
-
19
+
@@ -25,7 +25,7 @@ describe('Locale Completeness Validation', () => {
25
25
  });
26
26
  });
27
27
 
28
- it('all 17 tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(17);
28
+ it('all 18 tools registered', () => {
29
+ expect(ALL_TOOLS.length).toBe(18);
30
30
  });
31
31
  });
@@ -1,2 +1,2 @@
1
1
  export default function () { return null; }
2
-
2
+
@@ -45,4 +45,4 @@ describe('No H1 in Components', () => {
45
45
  });
46
46
  });
47
47
  });
48
-
48
+
@@ -1,94 +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
- });
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
+ });
@@ -1,23 +1,23 @@
1
- import { describe, it, expect } from 'vitest';
2
- import * as DATA from '../data';
3
-
4
- const ENTRIES = [
5
- { id: 'financeCategory', i18n: DATA.financeCategory.i18n },
6
- ];
7
-
8
- describe('SEO Content Length Validation', () => {
9
- ENTRIES.forEach((entry) => {
10
- describe(`Tool: ${entry.id}`, () => {
11
- Object.keys(entry.i18n).forEach((locale) => {
12
- it(`${locale}: SEO section should exist`, async () => {
13
- const loader = (entry.i18n as Record<string, () => Promise<{ seo?: unknown[] }>>)[locale];
14
- if (!loader) return;
15
- const content = await loader();
16
- if (!content.seo) return;
17
- expect(Array.isArray(content.seo)).toBe(true);
18
- });
19
- });
20
- });
21
- });
22
- });
23
-
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as DATA from '../data';
3
+
4
+ const ENTRIES = [
5
+ { id: 'financeCategory', i18n: DATA.financeCategory.i18n },
6
+ ];
7
+
8
+ describe('SEO Content Length Validation', () => {
9
+ ENTRIES.forEach((entry) => {
10
+ describe(`Tool: ${entry.id}`, () => {
11
+ Object.keys(entry.i18n).forEach((locale) => {
12
+ it(`${locale}: SEO section should exist`, async () => {
13
+ const loader = (entry.i18n as Record<string, () => Promise<{ seo?: unknown[] }>>)[locale];
14
+ if (!loader) return;
15
+ const content = await loader();
16
+ if (!content.seo) return;
17
+ expect(Array.isArray(content.seo)).toBe(true);
18
+ });
19
+ });
20
+ });
21
+ });
22
+ });
23
+