@jjlmoya/utils-sports 1.31.0 → 1.33.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 (181) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/fr.ts +7 -7
  3. package/src/category/i18n/ru.ts +3 -3
  4. package/src/category/index.ts +2 -0
  5. package/src/entries.ts +4 -1
  6. package/src/index.ts +2 -0
  7. package/src/layouts/PreviewLayout.astro +1 -1
  8. package/src/tests/diacritics_density.test.ts +118 -0
  9. package/src/tests/inverted_punctuation.test.ts +84 -0
  10. package/src/tests/locale_completeness.test.ts +1 -1
  11. package/src/tests/no_en_dash.test.ts +35 -6
  12. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  13. package/src/tests/script_density.test.ts +94 -0
  14. package/src/tests/tool_validation.test.ts +1 -1
  15. package/src/tool/baseballScoreKeeper/component.astro +2 -2
  16. package/src/tool/baseballScoreKeeper/i18n/de.ts +35 -35
  17. package/src/tool/baseballScoreKeeper/i18n/es.ts +37 -36
  18. package/src/tool/baseballScoreKeeper/i18n/fr.ts +38 -38
  19. package/src/tool/baseballScoreKeeper/i18n/it.ts +8 -8
  20. package/src/tool/baseballScoreKeeper/i18n/pl.ts +54 -54
  21. package/src/tool/baseballScoreKeeper/i18n/pt.ts +27 -27
  22. package/src/tool/baseballScoreKeeper/i18n/tr.ts +50 -50
  23. package/src/tool/basketScoreKeeper/component.astro +2 -2
  24. package/src/tool/basketScoreKeeper/i18n/de.ts +11 -11
  25. package/src/tool/basketScoreKeeper/i18n/en.ts +11 -11
  26. package/src/tool/basketScoreKeeper/i18n/es.ts +11 -11
  27. package/src/tool/basketScoreKeeper/i18n/fr.ts +14 -14
  28. package/src/tool/basketScoreKeeper/i18n/id.ts +11 -11
  29. package/src/tool/basketScoreKeeper/i18n/it.ts +11 -11
  30. package/src/tool/basketScoreKeeper/i18n/ja.ts +9 -9
  31. package/src/tool/basketScoreKeeper/i18n/ko.ts +9 -9
  32. package/src/tool/basketScoreKeeper/i18n/nl.ts +11 -11
  33. package/src/tool/basketScoreKeeper/i18n/pl.ts +11 -11
  34. package/src/tool/basketScoreKeeper/i18n/pt.ts +11 -11
  35. package/src/tool/basketScoreKeeper/i18n/ru.ts +17 -17
  36. package/src/tool/basketScoreKeeper/i18n/sv.ts +11 -11
  37. package/src/tool/basketScoreKeeper/i18n/tr.ts +12 -12
  38. package/src/tool/basketScoreKeeper/i18n/zh.ts +2 -2
  39. package/src/tool/basketScoreKeeper/logic.ts +13 -7
  40. package/src/tool/beachVolleyballScoreKeeper/component.astro +4 -4
  41. package/src/tool/beachVolleyballScoreKeeper/i18n/de.ts +26 -25
  42. package/src/tool/beachVolleyballScoreKeeper/i18n/es.ts +29 -28
  43. package/src/tool/beachVolleyballScoreKeeper/i18n/fr.ts +37 -36
  44. package/src/tool/beachVolleyballScoreKeeper/i18n/it.ts +15 -14
  45. package/src/tool/beachVolleyballScoreKeeper/i18n/pt.ts +32 -31
  46. package/src/tool/beachVolleyballScoreKeeper/logic.ts +4 -6
  47. package/src/tool/beachVolleyballScoreKeeper/render.ts +30 -31
  48. package/src/tool/dartsScoreKeeper/component.astro +2 -2
  49. package/src/tool/dartsScoreKeeper/i18n/de.ts +1 -1
  50. package/src/tool/dartsScoreKeeper/i18n/es.ts +1 -1
  51. package/src/tool/dartsScoreKeeper/i18n/fr.ts +1 -1
  52. package/src/tool/dartsScoreKeeper/i18n/id.ts +1 -1
  53. package/src/tool/dartsScoreKeeper/i18n/it.ts +1 -1
  54. package/src/tool/dartsScoreKeeper/i18n/ja.ts +1 -1
  55. package/src/tool/dartsScoreKeeper/i18n/ko.ts +1 -1
  56. package/src/tool/dartsScoreKeeper/i18n/nl.ts +1 -1
  57. package/src/tool/dartsScoreKeeper/i18n/pl.ts +1 -1
  58. package/src/tool/dartsScoreKeeper/i18n/pt.ts +1 -1
  59. package/src/tool/dartsScoreKeeper/i18n/ru.ts +1 -1
  60. package/src/tool/dartsScoreKeeper/i18n/sv.ts +1 -1
  61. package/src/tool/dartsScoreKeeper/i18n/tr.ts +1 -1
  62. package/src/tool/dartsScoreKeeper/i18n/zh.ts +1 -1
  63. package/src/tool/eloRatingCalculator/bibliography.astro +6 -0
  64. package/src/tool/eloRatingCalculator/bibliography.ts +16 -0
  65. package/src/tool/eloRatingCalculator/component.astro +270 -0
  66. package/src/tool/eloRatingCalculator/elo-rating-calculator.css +495 -0
  67. package/src/tool/eloRatingCalculator/entry.ts +30 -0
  68. package/src/tool/eloRatingCalculator/i18n/de.ts +215 -0
  69. package/src/tool/eloRatingCalculator/i18n/en.ts +215 -0
  70. package/src/tool/eloRatingCalculator/i18n/es.ts +215 -0
  71. package/src/tool/eloRatingCalculator/i18n/fr.ts +215 -0
  72. package/src/tool/eloRatingCalculator/i18n/id.ts +215 -0
  73. package/src/tool/eloRatingCalculator/i18n/it.ts +215 -0
  74. package/src/tool/eloRatingCalculator/i18n/ja.ts +215 -0
  75. package/src/tool/eloRatingCalculator/i18n/ko.ts +215 -0
  76. package/src/tool/eloRatingCalculator/i18n/nl.ts +215 -0
  77. package/src/tool/eloRatingCalculator/i18n/pl.ts +215 -0
  78. package/src/tool/eloRatingCalculator/i18n/pt.ts +215 -0
  79. package/src/tool/eloRatingCalculator/i18n/ru.ts +215 -0
  80. package/src/tool/eloRatingCalculator/i18n/sv.ts +215 -0
  81. package/src/tool/eloRatingCalculator/i18n/tr.ts +215 -0
  82. package/src/tool/eloRatingCalculator/i18n/zh.ts +215 -0
  83. package/src/tool/eloRatingCalculator/index.ts +9 -0
  84. package/src/tool/eloRatingCalculator/logic.ts +46 -0
  85. package/src/tool/eloRatingCalculator/seo.astro +15 -0
  86. package/src/tool/eloRatingCalculator/ui.ts +39 -0
  87. package/src/tool/footballScoreKeeper/component.astro +2 -2
  88. package/src/tool/footballScoreKeeper/i18n/de.ts +15 -15
  89. package/src/tool/footballScoreKeeper/i18n/en.ts +3 -3
  90. package/src/tool/footballScoreKeeper/i18n/es.ts +3 -3
  91. package/src/tool/footballScoreKeeper/i18n/fr.ts +5 -5
  92. package/src/tool/footballScoreKeeper/i18n/id.ts +3 -3
  93. package/src/tool/footballScoreKeeper/i18n/it.ts +3 -3
  94. package/src/tool/footballScoreKeeper/i18n/ko.ts +3 -3
  95. package/src/tool/footballScoreKeeper/i18n/nl.ts +8 -8
  96. package/src/tool/footballScoreKeeper/i18n/pl.ts +14 -14
  97. package/src/tool/footballScoreKeeper/i18n/pt.ts +3 -3
  98. package/src/tool/footballScoreKeeper/i18n/ru.ts +14 -14
  99. package/src/tool/footballScoreKeeper/i18n/sv.ts +9 -9
  100. package/src/tool/footballScoreKeeper/i18n/tr.ts +3 -3
  101. package/src/tool/footballScoreKeeper/logic.ts +3 -6
  102. package/src/tool/gymTracker/component.astro +1 -1
  103. package/src/tool/gymTracker/i18n/fr.ts +25 -24
  104. package/src/tool/gymTracker/i18n/ru.ts +4 -4
  105. package/src/tool/padelScoreKeeper/component.astro +2 -2
  106. package/src/tool/padelScoreKeeper/i18n/de.ts +1 -1
  107. package/src/tool/padelScoreKeeper/i18n/en.ts +1 -1
  108. package/src/tool/padelScoreKeeper/i18n/es.ts +1 -1
  109. package/src/tool/padelScoreKeeper/i18n/fr.ts +3 -3
  110. package/src/tool/padelScoreKeeper/i18n/id.ts +1 -1
  111. package/src/tool/padelScoreKeeper/i18n/it.ts +1 -1
  112. package/src/tool/padelScoreKeeper/i18n/ja.ts +1 -1
  113. package/src/tool/padelScoreKeeper/i18n/ko.ts +1 -1
  114. package/src/tool/padelScoreKeeper/i18n/nl.ts +1 -1
  115. package/src/tool/padelScoreKeeper/i18n/pl.ts +1 -1
  116. package/src/tool/padelScoreKeeper/i18n/pt.ts +1 -1
  117. package/src/tool/padelScoreKeeper/i18n/ru.ts +2 -2
  118. package/src/tool/padelScoreKeeper/i18n/sv.ts +1 -1
  119. package/src/tool/padelScoreKeeper/i18n/tr.ts +1 -1
  120. package/src/tool/padelScoreKeeper/i18n/zh.ts +1 -1
  121. package/src/tool/pingPongScoreKeeper/component.astro +2 -2
  122. package/src/tool/pingPongScoreKeeper/i18n/de.ts +2 -2
  123. package/src/tool/pingPongScoreKeeper/i18n/en.ts +2 -2
  124. package/src/tool/pingPongScoreKeeper/i18n/es.ts +2 -2
  125. package/src/tool/pingPongScoreKeeper/i18n/fr.ts +2 -2
  126. package/src/tool/pingPongScoreKeeper/i18n/id.ts +2 -2
  127. package/src/tool/pingPongScoreKeeper/i18n/it.ts +2 -2
  128. package/src/tool/pingPongScoreKeeper/i18n/ja.ts +2 -2
  129. package/src/tool/pingPongScoreKeeper/i18n/ko.ts +2 -2
  130. package/src/tool/pingPongScoreKeeper/i18n/nl.ts +2 -2
  131. package/src/tool/pingPongScoreKeeper/i18n/pl.ts +2 -2
  132. package/src/tool/pingPongScoreKeeper/i18n/pt.ts +2 -2
  133. package/src/tool/pingPongScoreKeeper/i18n/ru.ts +2 -2
  134. package/src/tool/pingPongScoreKeeper/i18n/sv.ts +2 -2
  135. package/src/tool/pingPongScoreKeeper/i18n/tr.ts +2 -2
  136. package/src/tool/pingPongScoreKeeper/i18n/zh.ts +2 -2
  137. package/src/tool/pingPongScoreKeeper/logic.ts +3 -5
  138. package/src/tool/reactionTester/i18n/de.ts +4 -4
  139. package/src/tool/reactionTester/i18n/fr.ts +1 -1
  140. package/src/tool/reactionTester/i18n/ru.ts +4 -4
  141. package/src/tool/rugbyScoreKeeper/component.astro +4 -4
  142. package/src/tool/rugbyScoreKeeper/ui-init.ts +2 -3
  143. package/src/tool/scoreKeeper/component.astro +3 -3
  144. package/src/tool/scoreKeeper/i18n/fr.ts +2 -2
  145. package/src/tool/scoreKeeper/i18n/ru.ts +7 -7
  146. package/src/tool/scoreKeeper/i18n/zh.ts +4 -4
  147. package/src/tool/snookerScoreKeeper/component.astro +2 -2
  148. package/src/tool/snookerScoreKeeper/i18n/ru.ts +1 -1
  149. package/src/tool/snookerScoreKeeper/logic.ts +1 -2
  150. package/src/tool/streetballScoreKeeper/component.astro +2 -2
  151. package/src/tool/streetballScoreKeeper/logic.ts +3 -6
  152. package/src/tool/streetballScoreKeeper/render.ts +5 -10
  153. package/src/tool/streetballScoreKeeper/streetball-3x3-basketball-scorekeeper.css +121 -0
  154. package/src/tool/tennisScoreKeeper/component.astro +2 -2
  155. package/src/tool/tennisScoreKeeper/effects.ts +47 -0
  156. package/src/tool/tennisScoreKeeper/i18n/de.ts +1 -1
  157. package/src/tool/tennisScoreKeeper/i18n/es.ts +1 -1
  158. package/src/tool/tennisScoreKeeper/i18n/fr.ts +1 -1
  159. package/src/tool/tennisScoreKeeper/i18n/id.ts +1 -1
  160. package/src/tool/tennisScoreKeeper/i18n/it.ts +1 -1
  161. package/src/tool/tennisScoreKeeper/i18n/ja.ts +1 -1
  162. package/src/tool/tennisScoreKeeper/i18n/ko.ts +1 -1
  163. package/src/tool/tennisScoreKeeper/i18n/nl.ts +1 -1
  164. package/src/tool/tennisScoreKeeper/i18n/pl.ts +1 -1
  165. package/src/tool/tennisScoreKeeper/i18n/pt.ts +1 -1
  166. package/src/tool/tennisScoreKeeper/i18n/ru.ts +5 -5
  167. package/src/tool/tennisScoreKeeper/i18n/sv.ts +1 -1
  168. package/src/tool/tennisScoreKeeper/i18n/tr.ts +1 -1
  169. package/src/tool/tennisScoreKeeper/i18n/zh.ts +1 -1
  170. package/src/tool/tennisScoreKeeper/logic.ts +4 -3
  171. package/src/tool/tennisScoreKeeper/render.ts +2 -44
  172. package/src/tool/tournamentBracket/component.astro +2 -2
  173. package/src/tool/tournamentBracket/i18n/es.ts +1 -1
  174. package/src/tool/tournamentBracket/i18n/fr.ts +2 -2
  175. package/src/tool/tournamentBracket/i18n/ja.ts +1 -1
  176. package/src/tool/tournamentBracket/i18n/pl.ts +8 -8
  177. package/src/tool/tournamentBracket/i18n/ru.ts +8 -8
  178. package/src/tool/tournamentBracket/i18n/zh.ts +7 -7
  179. package/src/tool/tournamentBracket/ui/bracket-desktop.ts +19 -13
  180. package/src/tool/tournamentBracket/ui/navigator.ts +15 -7
  181. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-sports",
3
- "version": "1.31.0",
3
+ "version": "1.33.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: 'Science de la Performance Sportive : Les Données pour Gagner',
11
+ text: 'Science de la Performance Sportive: Les Données pour Gagner',
12
12
  level: 2,
13
13
  },
14
14
  {
@@ -17,11 +17,11 @@ export const content: CategoryLocaleContent = {
17
17
  },
18
18
  {
19
19
  type: 'paragraph',
20
- html: 'De la mesure de la vitesse de réaction neuromusculaire à la gestion des tableaux d\'élimination de tournois, nos calculateurs appliquent une logique algorithmique et statistique pour que vous vous concentriez sur l\'essentiel : dépasser vos limites.',
20
+ html: 'De la mesure de la vitesse de réaction neuromusculaire à la gestion des tableaux d\'élimination de tournois, nos calculateurs appliquent une logique algorithmique et statistique pour que vous vous concentriez sur l\'essentiel: dépasser vos limites.',
21
21
  },
22
22
  {
23
23
  type: 'title',
24
- text: 'Logistique Compétitive : Tableaux de Score et Organisateurs',
24
+ text: 'Logistique Compétitive: Tableaux de Score et Organisateurs',
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: 'Entraînement en Force : Suivi Basé sur la Progression',
37
+ text: 'Entraînement en Force: Suivi Basé sur la Progression',
38
38
  level: 2,
39
39
  },
40
40
  {
@@ -43,7 +43,7 @@ export const content: CategoryLocaleContent = {
43
43
  },
44
44
  {
45
45
  type: 'title',
46
- text: 'Neurologie et Réflexes : Vitesse de Réaction',
46
+ text: 'Neurologie et Réflexes: Vitesse de Réaction',
47
47
  level: 2,
48
48
  },
49
49
  {
@@ -66,12 +66,12 @@ export const content: CategoryLocaleContent = {
66
66
  },
67
67
  {
68
68
  type: 'title',
69
- text: 'Physiologie de l\'Entraînement : Zones de Fréquence Cardiaque',
69
+ text: 'Physiologie de l\'Entraînement: Zones de Fréquence Cardiaque',
70
70
  level: 2,
71
71
  },
72
72
  {
73
73
  type: 'paragraph',
74
- html: 'Votre cœur est l\'indicateur le plus précis de l\'effort. La fréquence cardiaque maximale (FCM) varie selon l\'âge, la génétique et la capacité cardiovasculaire. La calculer correctement permet de s\'entraîner dans des zones spécifiques : zone aérobie (60-70% FCM) pour l\'endurance de base, zone anaérobie (80-90% FCM) pour l\'explosivité, zone VO2 max (95-100%) pour la capacité cardio-respiratoire.',
74
+ html: 'Votre cœur est l\'indicateur le plus précis de l\'effort. La fréquence cardiaque maximale (FCM) varie selon l\'âge, la génétique et la capacité cardiovasculaire. La calculer correctement permet de s\'entraîner dans des zones spécifiques: zone aérobie (60-70% FCM) pour l\'endurance de base, zone anaérobie (80-90% FCM) pour l\'explosivité, zone VO2 max (95-100%) pour la capacité cardio-respiratoire.',
75
75
  },
76
76
  {
77
77
  type: 'title',
@@ -17,7 +17,7 @@ export const content: CategoryLocaleContent = {
17
17
  },
18
18
  {
19
19
  type: 'paragraph',
20
- html: 'От измерения скорости нервно-мышечной реакции до управления турнирными сетками на выбывание : наши калькуляторы применяют алгоритмическую и статистическую логику, чтобы вы могли сосредоточиться на самом важном: раздвижении своих границ.',
20
+ html: 'От измерения скорости нервно-мышечной реакции до управления турнирными сетками на выбывание: наши калькуляторы применяют алгоритмическую и статистическую логику, чтобы вы могли сосредоточиться на самом важном: раздвижении своих границ.',
21
21
  },
22
22
  {
23
23
  type: 'title',
@@ -71,11 +71,11 @@ export const content: CategoryLocaleContent = {
71
71
  },
72
72
  {
73
73
  type: 'paragraph',
74
- html: 'Ваше сердце : самый точный индикатор усилий. Максимальная частота сердечных сокращений (МЧСС) зависит от возраста, генетики и сердечно-сосудистых способностей. Правильный расчет позволяет тренироваться в определенных зонах: аэробная зона (60-70% МЧСС) для базовой выносливости, анаэробная зона (80-90% МЧСС) для взрывной активности, зона VO2 max (95-100%) для кардиореспираторной способности.',
74
+ html: 'Ваше сердце: самый точный индикатор усилий. Максимальная частота сердечных сокращений (МЧСС) зависит от возраста, генетики и сердечно-сосудистых способностей. Правильный расчет позволяет тренироваться в определенных зонах: аэробная зона (60-70% МЧСС) для базовой выносливости, анаэробная зона (80-90% МЧСС) для взрывной активности, зона VO2 max (95-100%) для кардиореспираторной способности.',
75
75
  },
76
76
  {
77
77
  type: 'paragraph',
78
- html: 'Распространенная ошибка : тренироваться слишком интенсивно слишком часто. Большая часть тренировок должна проходить в аэробной зоне (низкая интенсивность, большая продолжительность), создавая прочный фундамент. Оптимально проводить всего 1-2 сессии в неделю в высокоинтенсивных зонах во избежание перетренированности. Наши инструменты позволяют рассчитать зоны на основе возраста и уровня физической подготовки, гарантируя, что каждая сессия имеет четкую физиологическую цель.',
78
+ html: 'Распространенная ошибка: тренироваться слишком интенсивно слишком часто. Большая часть тренировок должна проходить в аэробной зоне (низкая интенсивность, большая продолжительность), создавая прочный фундамент. Оптимально проводить всего 1-2 сессии в неделю в высокоинтенсивных зонах во избежание перетренированности. Наши инструменты позволяют рассчитать зоны на основе возраста и уровня физической подготовки, гарантируя, что каждая сессия имеет четкую физиологическую цель.',
79
79
  },
80
80
  {
81
81
  type: 'title',
@@ -5,6 +5,7 @@ import { scoreKeeper } from '../tool/scoreKeeper/index';
5
5
  import { tournamentBracket } from '../tool/tournamentBracket/index';
6
6
  import { gymTracker } from '../tool/gymTracker/index';
7
7
  import { reactionTester } from '../tool/reactionTester/index';
8
+ import { eloRatingCalculator } from '../tool/eloRatingCalculator/index';
8
9
  import { pingPongScoreKeeper } from '../tool/pingPongScoreKeeper/index';
9
10
  import { rugbyScoreKeeper } from '../tool/rugbyScoreKeeper/index';
10
11
  import { baseballScoreKeeper } from '../tool/baseballScoreKeeper/index';
@@ -18,6 +19,7 @@ export const sportsCategory: SportsCategoryEntry = {
18
19
  tournamentBracket,
19
20
  gymTracker,
20
21
  reactionTester,
22
+ eloRatingCalculator,
21
23
  pingPongScoreKeeper,
22
24
  rugbyScoreKeeper,
23
25
  baseballScoreKeeper,
package/src/entries.ts CHANGED
@@ -8,6 +8,8 @@ export { pingPongScoreKeeper } from './tool/pingPongScoreKeeper/entry';
8
8
  export type { PingPongScoreKeeperLocaleContent } from './tool/pingPongScoreKeeper/entry';
9
9
  export { reactionTester } from './tool/reactionTester/entry';
10
10
  export type { ReactionTesterLocaleContent } from './tool/reactionTester/entry';
11
+ export { eloRatingCalculator } from './tool/eloRatingCalculator/entry';
12
+ export type { EloRatingCalculatorLocaleContent } from './tool/eloRatingCalculator/entry';
11
13
  export { scoreKeeper } from './tool/scoreKeeper/entry';
12
14
  export type { ScoreKeeperLocaleContent } from './tool/scoreKeeper/entry';
13
15
  export { tournamentBracket } from './tool/tournamentBracket/entry';
@@ -34,6 +36,7 @@ import { footballScoreKeeper } from './tool/footballScoreKeeper/entry';
34
36
  import { gymTracker } from './tool/gymTracker/entry';
35
37
  import { pingPongScoreKeeper } from './tool/pingPongScoreKeeper/entry';
36
38
  import { reactionTester } from './tool/reactionTester/entry';
39
+ import { eloRatingCalculator } from './tool/eloRatingCalculator/entry';
37
40
  import { scoreKeeper } from './tool/scoreKeeper/entry';
38
41
  import { tournamentBracket } from './tool/tournamentBracket/entry';
39
42
  import { tennisScoreKeeper } from './tool/tennisScoreKeeper/entry';
@@ -44,5 +47,5 @@ import { beachVolleyballScoreKeeper } from './tool/beachVolleyballScoreKeeper/en
44
47
  import { snookerScoreKeeper } from './tool/snookerScoreKeeper/entry';
45
48
  import { rugbyScoreKeeper } from './tool/rugbyScoreKeeper/entry';
46
49
  import { baseballScoreKeeper } from './tool/baseballScoreKeeper/entry';
47
- export const ALL_ENTRIES = [basketScoreKeeper, footballScoreKeeper, gymTracker, pingPongScoreKeeper, reactionTester, scoreKeeper, tournamentBracket, tennisScoreKeeper, dartsScoreKeeper, padelScoreKeeper, streetballScoreKeeper, beachVolleyballScoreKeeper, snookerScoreKeeper, rugbyScoreKeeper, baseballScoreKeeper];
50
+ export const ALL_ENTRIES = [basketScoreKeeper, footballScoreKeeper, gymTracker, pingPongScoreKeeper, reactionTester, eloRatingCalculator, scoreKeeper, tournamentBracket, tennisScoreKeeper, dartsScoreKeeper, padelScoreKeeper, streetballScoreKeeper, beachVolleyballScoreKeeper, snookerScoreKeeper, rugbyScoreKeeper, baseballScoreKeeper];
48
51
 
package/src/index.ts CHANGED
@@ -29,3 +29,5 @@ export type { GymTrackerUI, GymTrackerLocaleContent } from './tool/gymTracker/in
29
29
  export { REACTION_TESTER_TOOL, reactionTester } from './tool/reactionTester/index';
30
30
  export type { ReactionTesterUI, ReactionTesterLocaleContent } from './tool/reactionTester/index';
31
31
 
32
+ export { ELO_RATING_CALCULATOR_TOOL, eloRatingCalculator } from './tool/eloRatingCalculator/index';
33
+ export type { EloRatingCalculatorUI, EloRatingCalculatorLocaleContent } from './tool/eloRatingCalculator/index';
@@ -78,7 +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
- }
81
+ font-family: Inter, sans-serif;}
82
82
 
83
83
  main {
84
84
  padding: 0 2rem;
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ type LocaleWithDiacritics = keyof typeof DIACRITIC_RULES;
5
+
6
+ const DIACRITIC_RULES = {
7
+ de: {
8
+ language: 'German',
9
+ expectedCharacters: 'ä ö ü ß',
10
+ characters: /[äöüÄÖÜß]/g,
11
+ minPerThousandLetters: 0.1,
12
+ },
13
+ es: {
14
+ language: 'Spanish',
15
+ expectedCharacters: 'á é í ó ú ü ñ',
16
+ characters: /[áéíóúüñÁÉÍÓÚÜÑ]/g,
17
+ minPerThousandLetters: 0.1,
18
+ },
19
+ fr: {
20
+ language: 'French',
21
+ expectedCharacters: 'à â æ ç é è ê ë î ï ô œ ù û ü ÿ',
22
+ characters: /[àâæçéèêëîïôœùûüÿÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸ]/g,
23
+ minPerThousandLetters: 0.1,
24
+ },
25
+ it: {
26
+ language: 'Italian',
27
+ expectedCharacters: 'à è é ì í î ò ó ù ú',
28
+ characters: /[àèéìíîòóùúÀÈÉÌÍÎÒÓÙÚ]/g,
29
+ minPerThousandLetters: 0.1,
30
+ },
31
+ pl: {
32
+ language: 'Polish',
33
+ expectedCharacters: 'ą ć ę ł ń ó ś ź ż',
34
+ characters: /[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g,
35
+ minPerThousandLetters: 0.1,
36
+ },
37
+ pt: {
38
+ language: 'Portuguese',
39
+ expectedCharacters: 'á â ã à ç é ê í ó ô õ ú ü',
40
+ characters: /[áâãàçéêíóôõúüÁÂÃÀÇÉÊÍÓÔÕÚÜ]/g,
41
+ minPerThousandLetters: 0.1,
42
+ },
43
+ sv: {
44
+ language: 'Swedish',
45
+ expectedCharacters: 'å ä ö',
46
+ characters: /[åäöÅÄÖ]/g,
47
+ minPerThousandLetters: 0.1,
48
+ },
49
+ tr: {
50
+ language: 'Turkish',
51
+ expectedCharacters: 'ç ğ ı İ ö ş ü',
52
+ characters: /[çğıöşüÇĞİÖŞÜ]/g,
53
+ minPerThousandLetters: 0.1,
54
+ },
55
+ } as const;
56
+
57
+ const LETTERS = /\p{L}/gu;
58
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
59
+
60
+ function collectStrings(value: unknown): string[] {
61
+ if (typeof value === 'string') return [value];
62
+ if (!value || typeof value !== 'object') return [];
63
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
64
+ return Object.values(value).flatMap(collectStrings);
65
+ }
66
+
67
+ function normalizeText(value: unknown): string {
68
+ return collectStrings(value).join(' ').normalize('NFC');
69
+ }
70
+
71
+ function translatableContent(content: Record<string, unknown>) {
72
+ return TRANSLATABLE_KEYS.map((key) => content[key]);
73
+ }
74
+
75
+ function letterCount(text: string): number {
76
+ return text.match(LETTERS)?.length ?? 0;
77
+ }
78
+
79
+ function diacriticCount(text: string, locale: LocaleWithDiacritics): number {
80
+ return text.match(DIACRITIC_RULES[locale].characters)?.length ?? 0;
81
+ }
82
+
83
+ function diacriticsPerThousandLetters(text: string, locale: LocaleWithDiacritics): number {
84
+ const letters = letterCount(text);
85
+ if (letters === 0) return 0;
86
+ return diacriticCount(text, locale) / letters * 1000;
87
+ }
88
+
89
+ describe('Diacritics density validation', () => {
90
+ ALL_TOOLS.forEach((tool) => {
91
+ describe(`Tool: ${tool.entry.id}`, () => {
92
+ Object.keys(DIACRITIC_RULES).forEach((locale) => {
93
+ it(`${locale} keeps the expected accent and special-letter set`, async () => {
94
+ const typedLocale = locale as LocaleWithDiacritics;
95
+ const loader = tool.entry.i18n[typedLocale];
96
+ if (!loader) return;
97
+
98
+ const content = await loader();
99
+ const text = normalizeText(translatableContent(content as Record<string, unknown>));
100
+ const rule = DIACRITIC_RULES[typedLocale];
101
+ const letters = letterCount(text);
102
+ const matches = diacriticCount(text, typedLocale);
103
+ const density = diacriticsPerThousandLetters(text, typedLocale);
104
+
105
+ expect(
106
+ density,
107
+ [
108
+ `Possible spelling or encoding issue detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
109
+ `The text has ${matches} special characters (${density.toFixed(2)} per 1000 letters, ${letters} letters analyzed).`,
110
+ `This locale should include some of these characters: ${rule.expectedCharacters}.`,
111
+ 'If the count is 0 or near 0, accents, tildes, or special letters were probably stripped by encoding or normalization.',
112
+ ].join(' '),
113
+ ).toBeGreaterThanOrEqual(rule.minPerThousandLetters);
114
+ });
115
+ });
116
+ });
117
+ });
118
+ });
@@ -0,0 +1,84 @@
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
+ function sentenceStart(text: string, endIndex: number): string {
30
+ const beforeMark = text.slice(0, endIndex).trimEnd();
31
+ const boundary = Math.max(
32
+ beforeMark.lastIndexOf('.'),
33
+ beforeMark.lastIndexOf(':'),
34
+ beforeMark.lastIndexOf(';'),
35
+ beforeMark.lastIndexOf('\n'),
36
+ );
37
+
38
+ return beforeMark.slice(boundary + 1).trimStart();
39
+ }
40
+
41
+ function findMissingInvertedMarks(
42
+ text: string,
43
+ startMark: string,
44
+ endMark: string,
45
+ ): string[] {
46
+ return [...text.matchAll(new RegExp(`\\${endMark}`, 'g'))]
47
+ .map((match) => sentenceStart(text, match.index ?? 0))
48
+ .filter((segment) => segment.length > 0 && !segment.includes(startMark))
49
+ .map((segment) => `${segment}${endMark}`);
50
+ }
51
+
52
+ describe('Inverted punctuation validation', () => {
53
+ ALL_TOOLS.forEach((tool) => {
54
+ describe(`Tool: ${tool.entry.id}`, () => {
55
+ Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
56
+ it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
57
+ const typedLocale = locale as InvertedPunctuationLocale;
58
+ const loader = tool.entry.i18n[typedLocale];
59
+ if (!loader) return;
60
+
61
+ const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
62
+ const content = await loader();
63
+ const strings = translatableStrings(content as Record<string, unknown>);
64
+ const missingQuestions = strings.flatMap((text) =>
65
+ findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
66
+ );
67
+ const missingExclamations = strings.flatMap((text) =>
68
+ findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd)
69
+ );
70
+ const failures = [...missingQuestions, ...missingExclamations];
71
+
72
+ expect(
73
+ failures,
74
+ [
75
+ `Missing opening punctuation marks in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
76
+ `Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
77
+ `Examples: ${failures.slice(0, 5).join(' | ')}`,
78
+ ].join(' '),
79
+ ).toHaveLength(0);
80
+ });
81
+ });
82
+ });
83
+ });
84
+ });
@@ -3,6 +3,6 @@ import { ALL_TOOLS } from '../tools';
3
3
 
4
4
  describe('Locale Completeness Validation', () => {
5
5
  it('all tools registered', () => {
6
- expect(ALL_TOOLS.length).toBe(15);
6
+ expect(ALL_TOOLS.length).toBe(16);
7
7
  });
8
8
  });
@@ -22,20 +22,49 @@ function getFiles(dir: string): string[] {
22
22
  return results;
23
23
  }
24
24
 
25
+ function isContentFile(filePath: string): boolean {
26
+ return /\\i18n\\/.test(filePath) || filePath.endsWith('bibliography.ts');
27
+ }
28
+
25
29
  const srcDir = path.join(process.cwd(), 'src');
26
30
  const scriptsDir = path.join(process.cwd(), 'scripts');
27
31
  const filesToTest = [
28
- ...getFiles(srcDir),
29
- ...getFiles(scriptsDir),
32
+ ...getFiles(srcDir).filter(isContentFile),
33
+ ...getFiles(scriptsDir).filter(isContentFile),
30
34
  ];
31
35
 
32
- describe('En-Dash Character Validation', () => {
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', () => {
33
51
  filesToTest.forEach((filePath) => {
34
52
  const relativePath = path.relative(process.cwd(), filePath);
35
- it(`should not contain en-dash in ${relativePath}`, () => {
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}`, () => {
36
66
  const content = fs.readFileSync(filePath, 'utf-8');
37
- const hasEnDash = content.includes('\u2013') || content.includes('–') || content.includes('—');
38
- expect(hasEnDash).toBe(false);
67
+ expect(content).not.toContain('--');
39
68
  });
40
69
  });
41
70
  });
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import type { SEOSection, ToolLocaleContent } from '../types';
6
+
7
+ const srcDir = join(process.cwd(), 'src');
8
+ const toolDir = join(srcDir, 'tool');
9
+ const geometryReads = [
10
+ 'offsetWidth',
11
+ 'offsetHeight',
12
+ 'offsetTop',
13
+ 'offsetLeft',
14
+ 'clientWidth',
15
+ 'clientHeight',
16
+ 'clientTop',
17
+ 'clientLeft',
18
+ 'scrollWidth',
19
+ 'scrollHeight',
20
+ 'scrollTop',
21
+ 'scrollLeft',
22
+ 'getBoundingClientRect',
23
+ 'getClientRects',
24
+ 'computedStyle',
25
+ 'getComputedStyle',
26
+ ];
27
+ const domWrites = [
28
+ '.style.',
29
+ '.classList.add',
30
+ '.classList.remove',
31
+ '.classList.toggle',
32
+ '.appendChild',
33
+ '.insertBefore',
34
+ '.prepend',
35
+ '.append',
36
+ '.remove',
37
+ '.innerHTML',
38
+ '.textContent',
39
+ '.setAttribute',
40
+ ];
41
+
42
+ function findFiles(dir: string, extensions: string[]): string[] {
43
+ const files: string[] = [];
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ const fullPath = join(dir, entry.name);
46
+ if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
47
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
48
+ }
49
+ return files;
50
+ }
51
+
52
+ function relativePath(file: string): string {
53
+ return relative(process.cwd(), file).replace(/\\/g, '/');
54
+ }
55
+
56
+ function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
57
+ return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
58
+ }
59
+
60
+ function attrValue(tag: string, attr: string): string | null {
61
+ const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
62
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
63
+ }
64
+
65
+ function booleanAttr(tag: string, attr: string): boolean {
66
+ return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
67
+ }
68
+
69
+ function controlStartIndex(content: string, tag: RegExpMatchArray): number {
70
+ return tag.index ?? content.indexOf(tag[0]);
71
+ }
72
+
73
+ function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
74
+ const index = controlStartIndex(content, tag);
75
+ const before = content.slice(0, index);
76
+ const labelOpen = before.lastIndexOf('<label');
77
+ const labelClose = before.lastIndexOf('</label>');
78
+ const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
79
+ return labelOpen > labelClose && nextLabelClose !== -1;
80
+ }
81
+
82
+ function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
83
+ const source = tag[0];
84
+ if (attrValue(source, 'aria-label')) return true;
85
+ if (attrValue(source, 'aria-labelledby')) return true;
86
+ const id = attrValue(source, 'id');
87
+ if (id && hasExplicitLabel(content, id)) return true;
88
+ return hasWrappingLabel(content, tag);
89
+ }
90
+
91
+ function isVisuallyHiddenFileInput(tag: string): boolean {
92
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
93
+ const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
94
+ const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
95
+ return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
96
+ }
97
+
98
+ function isIgnoredInput(tag: string): boolean {
99
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
100
+ return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
101
+ }
102
+
103
+ function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
104
+ return findFormControls(content, tagName)
105
+ .filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
106
+ .filter((tag) => !hasAccessibleName(content, tag))
107
+ .map((tag) => tag[0]);
108
+ }
109
+
110
+ function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
111
+ return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
112
+ }
113
+
114
+ function hasExplicitLabel(content: string, id: string): boolean {
115
+ const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ return (
117
+ new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
118
+ || new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
119
+ );
120
+ }
121
+
122
+ function headingLevels(sections: SEOSection[]): number[] {
123
+ return sections
124
+ .filter((section) => section.type === 'title')
125
+ .map((section) => Number('level' in section ? section.level : 0))
126
+ .filter((level) => Number.isInteger(level) && level > 0);
127
+ }
128
+
129
+ function findHeadingLevelJumps(levels: number[]): string[] {
130
+ const failures: string[] = [];
131
+ levels.forEach((level, index) => {
132
+ const previous = index === 0 ? 1 : levels[index - 1];
133
+ if (previous && level > previous + 1) {
134
+ failures.push(`h${previous} -> h${level}`);
135
+ }
136
+ });
137
+ return failures;
138
+ }
139
+
140
+ function hasDomWriteBeforeGeometryRead(content: string): boolean {
141
+ const normalized = content.replace(/\s+/g, ' ');
142
+ return domWrites.some((write) => {
143
+ const writeIndex = normalized.indexOf(write);
144
+ if (writeIndex === -1) return false;
145
+ return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
146
+ });
147
+ }
148
+
149
+ describe('PageSpeed best-practice guards', () => {
150
+ const astroToolFiles = findFiles(toolDir, ['.astro']);
151
+ const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
152
+
153
+ astroToolFiles.forEach((file) => {
154
+ const displayPath = relativePath(file);
155
+
156
+ it(`${displayPath} labels every input with an explicit label`, () => {
157
+ const content = readFileSync(file, 'utf-8');
158
+ const failures = controlFailures(content, 'input');
159
+
160
+ expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
161
+ });
162
+
163
+ it(`${displayPath} labels every select with an explicit label`, () => {
164
+ const content = readFileSync(file, 'utf-8');
165
+ const failures = controlFailures(content, 'select');
166
+
167
+ expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
168
+ });
169
+ });
170
+
171
+ ALL_TOOLS.forEach((tool) => {
172
+ Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
173
+ it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
174
+ if (!loader) return;
175
+ const content = (await loader()) as ToolLocaleContent;
176
+ const levels = headingLevels(content.seo);
177
+ const failures = findHeadingLevelJumps(levels);
178
+
179
+ expect(
180
+ failures,
181
+ `SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
182
+ ).toEqual([]);
183
+ });
184
+ });
185
+ });
186
+
187
+ scriptFiles.forEach((file) => {
188
+ const displayPath = relativePath(file);
189
+
190
+ it(`${displayPath} avoids static forced-reflow patterns`, () => {
191
+ const content = readFileSync(file, 'utf-8');
192
+ expect(
193
+ hasDomWriteBeforeGeometryRead(content),
194
+ `${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
195
+ ).toBe(false);
196
+ });
197
+ });
198
+ });