@jjlmoya/utils-pets 1.21.0 → 1.23.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 (138) hide show
  1. package/package.json +2 -2
  2. package/src/category/i18n/de.ts +1 -1
  3. package/src/category/i18n/en.ts +2 -2
  4. package/src/category/i18n/es.ts +1 -1
  5. package/src/category/i18n/fr.ts +1 -1
  6. package/src/category/i18n/pt.ts +1 -1
  7. package/src/category/i18n/tr.ts +1 -1
  8. package/src/category/index.ts +28 -27
  9. package/src/entries.ts +16 -13
  10. package/src/index.ts +1 -0
  11. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  12. package/src/tests/category_seo_quality.test.ts +74 -0
  13. package/src/tests/faq_count.test.ts +22 -22
  14. package/src/tests/i18n_coverage.test.ts +5 -2
  15. package/src/tests/locale_completeness.test.ts +31 -31
  16. package/src/tests/no_h1_in_components.test.ts +1 -1
  17. package/src/tests/pet_logic.test.ts +49 -0
  18. package/src/tests/qa-test-helpers.ts +32 -0
  19. package/src/tests/qa_bibliography_links.test.ts +40 -0
  20. package/src/tests/qa_claim_evidence.test.ts +69 -0
  21. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  22. package/src/tests/qa_runtime_i18n.test.ts +100 -0
  23. package/src/tests/seo_length.test.ts +54 -54
  24. package/src/tests/seo_parity.test.ts +2 -2
  25. package/src/tests/seo_wellformed_export.test.ts +65 -0
  26. package/src/tests/spanish_leakage.test.ts +175 -175
  27. package/src/tests/tool_validation.test.ts +139 -139
  28. package/src/tool/petAge/component.astro +3 -3
  29. package/src/tool/petAge/i18n/de.ts +184 -184
  30. package/src/tool/petAge/i18n/en.ts +184 -184
  31. package/src/tool/petAge/i18n/es.ts +184 -184
  32. package/src/tool/petAge/i18n/fr.ts +184 -184
  33. package/src/tool/petAge/i18n/id.ts +184 -184
  34. package/src/tool/petAge/i18n/it.ts +184 -184
  35. package/src/tool/petAge/i18n/ja.ts +184 -184
  36. package/src/tool/petAge/i18n/ko.ts +184 -184
  37. package/src/tool/petAge/i18n/nl.ts +184 -184
  38. package/src/tool/petAge/i18n/pl.ts +184 -184
  39. package/src/tool/petAge/i18n/pt.ts +184 -184
  40. package/src/tool/petAge/i18n/ru.ts +184 -184
  41. package/src/tool/petAge/i18n/sv.ts +184 -184
  42. package/src/tool/petAge/i18n/tr.ts +185 -185
  43. package/src/tool/petAge/i18n/zh.ts +184 -184
  44. package/src/tool/petAge/seo.astro +15 -15
  45. package/src/tool/petCarrierCrateSizePlanner/bibliography.astro +6 -0
  46. package/src/tool/petCarrierCrateSizePlanner/bibliography.ts +12 -0
  47. package/src/tool/petCarrierCrateSizePlanner/component.astro +89 -0
  48. package/src/tool/petCarrierCrateSizePlanner/controller.ts +128 -0
  49. package/src/tool/petCarrierCrateSizePlanner/dom-views.ts +96 -0
  50. package/src/tool/petCarrierCrateSizePlanner/entry.ts +27 -0
  51. package/src/tool/petCarrierCrateSizePlanner/evaluator.ts +14 -0
  52. package/src/tool/petCarrierCrateSizePlanner/i18n/de.ts +171 -0
  53. package/src/tool/petCarrierCrateSizePlanner/i18n/en.ts +171 -0
  54. package/src/tool/petCarrierCrateSizePlanner/i18n/es.ts +171 -0
  55. package/src/tool/petCarrierCrateSizePlanner/i18n/fr.ts +171 -0
  56. package/src/tool/petCarrierCrateSizePlanner/i18n/id.ts +171 -0
  57. package/src/tool/petCarrierCrateSizePlanner/i18n/it.ts +171 -0
  58. package/src/tool/petCarrierCrateSizePlanner/i18n/ja.ts +171 -0
  59. package/src/tool/petCarrierCrateSizePlanner/i18n/ko.ts +171 -0
  60. package/src/tool/petCarrierCrateSizePlanner/i18n/nl.ts +171 -0
  61. package/src/tool/petCarrierCrateSizePlanner/i18n/pl.ts +171 -0
  62. package/src/tool/petCarrierCrateSizePlanner/i18n/pt.ts +171 -0
  63. package/src/tool/petCarrierCrateSizePlanner/i18n/ru.ts +171 -0
  64. package/src/tool/petCarrierCrateSizePlanner/i18n/sv.ts +171 -0
  65. package/src/tool/petCarrierCrateSizePlanner/i18n/tr.ts +171 -0
  66. package/src/tool/petCarrierCrateSizePlanner/i18n/zh.ts +171 -0
  67. package/src/tool/petCarrierCrateSizePlanner/index.ts +12 -0
  68. package/src/tool/petCarrierCrateSizePlanner/logic.test.ts +49 -0
  69. package/src/tool/petCarrierCrateSizePlanner/logic.ts +77 -0
  70. package/src/tool/petCarrierCrateSizePlanner/pet-carrier-crate-size-planner.css +639 -0
  71. package/src/tool/petCarrierCrateSizePlanner/seo.astro +14 -0
  72. package/src/tool/petCarrierCrateSizePlanner/storage.ts +31 -0
  73. package/src/tool/petCarrierCrateSizePlanner/ui.ts +59 -0
  74. package/src/tool/petGestation/i18n/de.ts +0 -2
  75. package/src/tool/petGestation/i18n/en.ts +0 -2
  76. package/src/tool/petGestation/i18n/es.ts +0 -2
  77. package/src/tool/petGestation/i18n/fr.ts +0 -2
  78. package/src/tool/petGestation/i18n/id.ts +0 -2
  79. package/src/tool/petGestation/i18n/it.ts +0 -2
  80. package/src/tool/petGestation/i18n/ja.ts +0 -2
  81. package/src/tool/petGestation/i18n/ko.ts +0 -2
  82. package/src/tool/petGestation/i18n/nl.ts +0 -2
  83. package/src/tool/petGestation/i18n/pl.ts +0 -2
  84. package/src/tool/petGestation/i18n/pt.ts +0 -2
  85. package/src/tool/petGestation/i18n/ru.ts +0 -2
  86. package/src/tool/petGestation/i18n/sv.ts +0 -2
  87. package/src/tool/petGestation/i18n/tr.ts +0 -2
  88. package/src/tool/petGestation/i18n/zh.ts +0 -2
  89. package/src/tool/petGestation/ui.ts +0 -2
  90. package/src/tool/petRation/i18n/de.ts +181 -181
  91. package/src/tool/petRation/i18n/en.ts +181 -181
  92. package/src/tool/petRation/i18n/es.ts +181 -181
  93. package/src/tool/petRation/i18n/fr.ts +181 -181
  94. package/src/tool/petRation/i18n/id.ts +181 -181
  95. package/src/tool/petRation/i18n/it.ts +181 -181
  96. package/src/tool/petRation/i18n/ja.ts +181 -181
  97. package/src/tool/petRation/i18n/ko.ts +181 -181
  98. package/src/tool/petRation/i18n/nl.ts +181 -181
  99. package/src/tool/petRation/i18n/pl.ts +181 -181
  100. package/src/tool/petRation/i18n/pt.ts +181 -181
  101. package/src/tool/petRation/i18n/ru.ts +181 -181
  102. package/src/tool/petRation/i18n/sv.ts +181 -181
  103. package/src/tool/petRation/i18n/tr.ts +181 -181
  104. package/src/tool/petRation/i18n/zh.ts +181 -181
  105. package/src/tool/petRation/seo.astro +15 -15
  106. package/src/tool/petToxicity/i18n/de.ts +0 -2
  107. package/src/tool/petToxicity/i18n/en.ts +0 -2
  108. package/src/tool/petToxicity/i18n/es.ts +2 -4
  109. package/src/tool/petToxicity/i18n/fr.ts +0 -2
  110. package/src/tool/petToxicity/i18n/id.ts +0 -2
  111. package/src/tool/petToxicity/i18n/it.ts +0 -2
  112. package/src/tool/petToxicity/i18n/ja.ts +0 -2
  113. package/src/tool/petToxicity/i18n/ko.ts +0 -2
  114. package/src/tool/petToxicity/i18n/nl.ts +0 -2
  115. package/src/tool/petToxicity/i18n/pl.ts +0 -2
  116. package/src/tool/petToxicity/i18n/pt.ts +0 -2
  117. package/src/tool/petToxicity/i18n/ru.ts +0 -2
  118. package/src/tool/petToxicity/i18n/sv.ts +0 -2
  119. package/src/tool/petToxicity/i18n/tr.ts +0 -2
  120. package/src/tool/petToxicity/i18n/zh.ts +0 -2
  121. package/src/tool/petToxicity/ui.ts +0 -2
  122. package/src/tool/petWaterIntake/i18n/de.ts +0 -15
  123. package/src/tool/petWaterIntake/i18n/en.ts +0 -15
  124. package/src/tool/petWaterIntake/i18n/es.ts +2 -17
  125. package/src/tool/petWaterIntake/i18n/fr.ts +0 -15
  126. package/src/tool/petWaterIntake/i18n/id.ts +0 -15
  127. package/src/tool/petWaterIntake/i18n/it.ts +0 -15
  128. package/src/tool/petWaterIntake/i18n/ja.ts +0 -15
  129. package/src/tool/petWaterIntake/i18n/ko.ts +0 -15
  130. package/src/tool/petWaterIntake/i18n/nl.ts +0 -15
  131. package/src/tool/petWaterIntake/i18n/pl.ts +0 -15
  132. package/src/tool/petWaterIntake/i18n/pt.ts +0 -15
  133. package/src/tool/petWaterIntake/i18n/ru.ts +0 -15
  134. package/src/tool/petWaterIntake/i18n/sv.ts +0 -15
  135. package/src/tool/petWaterIntake/i18n/tr.ts +0 -15
  136. package/src/tool/petWaterIntake/i18n/zh.ts +0 -15
  137. package/src/tool/petWaterIntake/ui.ts +0 -15
  138. package/src/tools.ts +22 -19
@@ -1,175 +1,175 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { ALL_ENTRIES } from '../entries';
3
-
4
- const STRUCTURAL_KEYS = new Set([
5
- 'id',
6
- 'slug',
7
- 'url',
8
- 'icon',
9
- 'image',
10
- 'imageUrl',
11
- 'keywords',
12
- 'category',
13
- 'tags',
14
- 'toolId',
15
- 'type',
16
- 'locale',
17
- 'language',
18
- 'direction',
19
- ]);
20
-
21
- const TRANSLATABLE_KEYS = [
22
- 'title',
23
- 'description',
24
- 'faqTitle',
25
- 'faq',
26
- 'howTo',
27
- 'seo',
28
- 'schemas',
29
- ] as const;
30
-
31
- const COPY_THRESHOLD = 0.9;
32
-
33
- const SPANISH_MARKERS = [
34
- ['sangre', /\bsangre\b/gi],
35
- ['molino', /\bmolino\b/gi],
36
- ['grano', /\bgrano\b/gi],
37
- ['paladar', /\bpaladar\b/gi],
38
- ['cuchillas', /\bcuchillas\b/gi],
39
- ['trozos', /\btrozos\b/gi],
40
- ['frescura', /\bfrescura\b/gi],
41
- ['ingresa', /\bingresa\b/gi],
42
- ['selecciona', /\bselecciona\b/gi],
43
- ['herramienta', /\bherramienta\b/gi],
44
- ['según', /\bsegún\b/gi],
45
- ['después', /\bdespués\b/gi],
46
- ['puedes', /\bpuedes\b/gi],
47
- ['debes', /\bdebes\b/gi],
48
- ['tus', /\btus\b/gi],
49
- ['a la vez', /\ba la vez\b/gi],
50
- ['los datos', /\blos datos\b/gi],
51
- ['las opciones', /\blas opciones\b/gi],
52
- ['el resultado', /\bel resultado\b/gi],
53
- ['método de extracción', /\bmétodo de extracción\b/gi],
54
- ['uniformidad del molino', /\buniformidad del molino\b/gi],
55
- ['café recién tostado', /\bcafé recién tostado\b/gi],
56
- ] as const;
57
-
58
- type UnknownRecord = Record<string, unknown>;
59
-
60
- function normalize(value: string): string {
61
- return value
62
- .replace(/<[^>]*>/g, ' ')
63
- .replace(/&nbsp;/gi, ' ')
64
- .replace(/\s+/g, ' ')
65
- .trim()
66
- .toLocaleLowerCase('es');
67
- }
68
-
69
- function isTechnicalInvariant(text: string): boolean {
70
- const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
71
- return [
72
- 'data:image/svg+xml;base64',
73
- 'background-image: url',
74
- '.layout-playground {',
75
- 'const samplerate =',
76
- ].some((pattern) => text.includes(pattern)) ||
77
- (text.includes('presets') && text.includes('hz')) ||
78
- (text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
79
- (text.includes('led') && ledUnitMatches.length >= 3);
80
- }
81
-
82
- function collectString(value: string, output: string[]): void {
83
- const text = normalize(value);
84
- if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
85
- }
86
-
87
- function collectObject(value: UnknownRecord, output: string[]): void {
88
- Object.entries(value).forEach(([childKey, childValue]) => {
89
- collectText(childValue, output, childKey);
90
- });
91
- }
92
-
93
- function collectText(value: unknown, output: string[], key?: string): void {
94
- if (key && STRUCTURAL_KEYS.has(key)) return;
95
- if (typeof value === 'string') return collectString(value, output);
96
- if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
97
- if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
98
- }
99
-
100
- async function loadText(loader: unknown): Promise<string[]> {
101
- if (typeof loader !== 'function') return [];
102
- const module = await (loader as () => Promise<unknown>)();
103
- const output: string[] = [];
104
- if (module && typeof module === 'object') {
105
- const record = module as UnknownRecord;
106
- for (const key of TRANSLATABLE_KEYS) {
107
- collectText(record[key], output, key);
108
- }
109
- }
110
- return output;
111
- }
112
-
113
- function findSpanishMarkers(text: string[]): string[] {
114
- const corpus = text.join(' ');
115
- return SPANISH_MARKERS.flatMap(([label, pattern]) =>
116
- pattern.test(corpus) ? [label] : [],
117
- );
118
- }
119
-
120
- function similarity(left: string, right: string): number {
121
- const leftTokens = left.split(/\s+/);
122
- const rightCounts = new Map<string, number>();
123
- right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
124
- const matches = leftTokens.reduce((total, token) => {
125
- const count = rightCounts.get(token) ?? 0;
126
- if (count > 0) rightCounts.set(token, count - 1);
127
- return total + (count > 0 ? 1 : 0);
128
- }, 0);
129
- return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
130
- }
131
-
132
- function findCopiedFragments(spanish: string[], translated: string[]): string[] {
133
- return spanish
134
- .filter((fragment) => fragment.length >= 80)
135
- .filter((fragment) => translated.some((candidate) =>
136
- candidate.length >= 80 &&
137
- Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
138
- similarity(fragment, candidate) >= COPY_THRESHOLD,
139
- ))
140
- .sort((a, b) => b.length - a.length)
141
- .slice(0, 3);
142
- }
143
-
144
- describe('Locales must not contain copied Spanish content', () => {
145
- for (const entry of ALL_ENTRIES) {
146
- it(`${entry.id} has no untranslated Spanish blocks`, async () => {
147
- const spanish = await loadText(entry.i18n.es);
148
- const failures: string[] = [];
149
-
150
- for (const [locale, loader] of Object.entries(entry.i18n)) {
151
- if (locale === 'es') continue;
152
-
153
- const translated = await loadText(loader);
154
- const copiedFragments = findCopiedFragments(spanish, translated);
155
- const markerHits = findSpanishMarkers(translated);
156
-
157
- if (copiedFragments.length > 0 || markerHits.length >= 2) {
158
- const details = [
159
- copiedFragments.length > 0
160
- ? `copied fragments: ${copiedFragments
161
- .map((fragment) => JSON.stringify(fragment.slice(0, 120)))
162
- .join(', ')}`
163
- : '',
164
- markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
165
- ]
166
- .filter(Boolean)
167
- .join('; ');
168
- failures.push(`${locale}: ${details}`);
169
- }
170
- }
171
-
172
- expect(failures, failures.join('\n')).toEqual([]);
173
- });
174
- }
175
- });
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_ENTRIES } from '../entries';
3
+
4
+ const STRUCTURAL_KEYS = new Set([
5
+ 'id',
6
+ 'slug',
7
+ 'url',
8
+ 'icon',
9
+ 'image',
10
+ 'imageUrl',
11
+ 'keywords',
12
+ 'category',
13
+ 'tags',
14
+ 'toolId',
15
+ 'type',
16
+ 'locale',
17
+ 'language',
18
+ 'direction',
19
+ ]);
20
+
21
+ const TRANSLATABLE_KEYS = [
22
+ 'title',
23
+ 'description',
24
+ 'faqTitle',
25
+ 'faq',
26
+ 'howTo',
27
+ 'seo',
28
+ 'schemas',
29
+ ] as const;
30
+
31
+ const COPY_THRESHOLD = 0.9;
32
+
33
+ const SPANISH_MARKERS = [
34
+ ['sangre', /\bsangre\b/gi],
35
+ ['molino', /\bmolino\b/gi],
36
+ ['grano', /\bgrano\b/gi],
37
+ ['paladar', /\bpaladar\b/gi],
38
+ ['cuchillas', /\bcuchillas\b/gi],
39
+ ['trozos', /\btrozos\b/gi],
40
+ ['frescura', /\bfrescura\b/gi],
41
+ ['ingresa', /\bingresa\b/gi],
42
+ ['selecciona', /\bselecciona\b/gi],
43
+ ['herramienta', /\bherramienta\b/gi],
44
+ ['según', /\bsegún\b/gi],
45
+ ['después', /\bdespués\b/gi],
46
+ ['puedes', /\bpuedes\b/gi],
47
+ ['debes', /\bdebes\b/gi],
48
+ ['tus', /\btus\b/gi],
49
+ ['a la vez', /\ba la vez\b/gi],
50
+ ['los datos', /\blos datos\b/gi],
51
+ ['las opciones', /\blas opciones\b/gi],
52
+ ['el resultado', /\bel resultado\b/gi],
53
+ ['método de extracción', /\bmétodo de extracción\b/gi],
54
+ ['uniformidad del molino', /\buniformidad del molino\b/gi],
55
+ ['café recién tostado', /\bcafé recién tostado\b/gi],
56
+ ] as const;
57
+
58
+ type UnknownRecord = Record<string, unknown>;
59
+
60
+ function normalize(value: string): string {
61
+ return value
62
+ .replace(/<[^>]*>/g, ' ')
63
+ .replace(/&nbsp;/gi, ' ')
64
+ .replace(/\s+/g, ' ')
65
+ .trim()
66
+ .toLocaleLowerCase('es');
67
+ }
68
+
69
+ function isTechnicalInvariant(text: string): boolean {
70
+ const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
71
+ return [
72
+ 'data:image/svg+xml;base64',
73
+ 'background-image: url',
74
+ '.layout-playground {',
75
+ 'const samplerate =',
76
+ ].some((pattern) => text.includes(pattern)) ||
77
+ (text.includes('presets') && text.includes('hz')) ||
78
+ (text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
79
+ (text.includes('led') && ledUnitMatches.length >= 3);
80
+ }
81
+
82
+ function collectString(value: string, output: string[]): void {
83
+ const text = normalize(value);
84
+ if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
85
+ }
86
+
87
+ function collectObject(value: UnknownRecord, output: string[]): void {
88
+ Object.entries(value).forEach(([childKey, childValue]) => {
89
+ collectText(childValue, output, childKey);
90
+ });
91
+ }
92
+
93
+ function collectText(value: unknown, output: string[], key?: string): void {
94
+ if (key && STRUCTURAL_KEYS.has(key)) return;
95
+ if (typeof value === 'string') return collectString(value, output);
96
+ if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
97
+ if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
98
+ }
99
+
100
+ async function loadText(loader: unknown): Promise<string[]> {
101
+ if (typeof loader !== 'function') return [];
102
+ const module = await (loader as () => Promise<unknown>)();
103
+ const output: string[] = [];
104
+ if (module && typeof module === 'object') {
105
+ const record = module as UnknownRecord;
106
+ for (const key of TRANSLATABLE_KEYS) {
107
+ collectText(record[key], output, key);
108
+ }
109
+ }
110
+ return output;
111
+ }
112
+
113
+ function findSpanishMarkers(text: string[]): string[] {
114
+ const corpus = text.join(' ');
115
+ return SPANISH_MARKERS.flatMap(([label, pattern]) =>
116
+ pattern.test(corpus) ? [label] : [],
117
+ );
118
+ }
119
+
120
+ function similarity(left: string, right: string): number {
121
+ const leftTokens = left.split(/\s+/);
122
+ const rightCounts = new Map<string, number>();
123
+ right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
124
+ const matches = leftTokens.reduce((total, token) => {
125
+ const count = rightCounts.get(token) ?? 0;
126
+ if (count > 0) rightCounts.set(token, count - 1);
127
+ return total + (count > 0 ? 1 : 0);
128
+ }, 0);
129
+ return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
130
+ }
131
+
132
+ function findCopiedFragments(spanish: string[], translated: string[]): string[] {
133
+ return spanish
134
+ .filter((fragment) => fragment.length >= 80)
135
+ .filter((fragment) => translated.some((candidate) =>
136
+ candidate.length >= 80 &&
137
+ Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
138
+ similarity(fragment, candidate) >= COPY_THRESHOLD,
139
+ ))
140
+ .sort((a, b) => b.length - a.length)
141
+ .slice(0, 3);
142
+ }
143
+
144
+ describe('Locales must not contain copied Spanish content', () => {
145
+ for (const entry of ALL_ENTRIES) {
146
+ it(`${entry.id} has no untranslated Spanish blocks`, async () => {
147
+ const spanish = await loadText(entry.i18n.es);
148
+ const failures: string[] = [];
149
+
150
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
151
+ if (locale === 'es') continue;
152
+
153
+ const translated = await loadText(loader);
154
+ const copiedFragments = findCopiedFragments(spanish, translated);
155
+ const markerHits = findSpanishMarkers(translated);
156
+
157
+ if (copiedFragments.length > 0 || markerHits.length >= 2) {
158
+ const details = [
159
+ copiedFragments.length > 0
160
+ ? `copied fragments: ${copiedFragments
161
+ .map((fragment) => JSON.stringify(fragment.slice(0, 120)))
162
+ .join(', ')}`
163
+ : '',
164
+ markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
165
+ ]
166
+ .filter(Boolean)
167
+ .join('; ');
168
+ failures.push(`${locale}: ${details}`);
169
+ }
170
+ }
171
+
172
+ expect(failures, failures.join('\n')).toEqual([]);
173
+ });
174
+ }
175
+ });
@@ -1,139 +1,139 @@
1
- import { describe, it, expect } from 'vitest';
2
- import * as fs from 'fs';
3
- import * as path from 'path';
4
- import { ALL_TOOLS } from '../tools';
5
- import { petsCategory } from '../data';
6
- import type { ToolDefinition } from '../types';
7
-
8
- function extractSeoText(sections: any[]): string {
9
- return sections
10
- .map((section) => extractSectionText(section))
11
- .join(' ');
12
- }
13
-
14
- function extractHtmlOrText(section: any): string {
15
- const hasContent = 'html' in section || 'text' in section;
16
- const isNotTable = section.type !== 'table';
17
- return hasContent && isNotTable ? (section.html || section.text || '') : '';
18
- }
19
-
20
- function extractListItems(section: any): string {
21
- return 'items' in section && Array.isArray(section.items) ? section.items.join(' ') : '';
22
- }
23
-
24
- function extractTableRows(section: any): string {
25
- return 'rows' in section && Array.isArray(section.rows) ? section.rows.flat().join(' ') : '';
26
- }
27
-
28
- function extractSectionText(section: any): string {
29
- let text = '';
30
- text += extractHtmlOrText(section);
31
- text += extractListItems(section);
32
- text += extractTableRows(section);
33
- return text;
34
- }
35
-
36
- function countWords(text: string): number {
37
- return text
38
- .replace(/<[^>]*>/g, '')
39
- .trim()
40
- .split(/\s+/)
41
- .filter((w) => w.length > 0).length;
42
- }
43
-
44
- describe('Tool Validation Suite', () => {
45
- describe('Strict Tool Definition Validation', () => {
46
- ALL_TOOLS.forEach((tool: ToolDefinition) => {
47
- const { entry } = tool;
48
-
49
- describe(`${entry.id} structure`, () => {
50
- it('should fulfill ToolDefinition interface', () => {
51
- expect(tool.entry).toBeDefined();
52
- expect(tool.Component).toBeDefined();
53
- expect(tool.SEOComponent).toBeDefined();
54
- expect(tool.BibliographyComponent).toBeDefined();
55
- });
56
-
57
- it('should have valid ID in kebab-case', () => {
58
- expect(entry.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
59
- });
60
-
61
- it('should have valid icons with bg and fg', () => {
62
- expect(entry.icons.bg).toMatch(/^mdi:/);
63
- expect(entry.icons.fg).toMatch(/^mdi:/);
64
- });
65
-
66
- describe('i18n Content Validation', () => {
67
- Object.entries(entry.i18n).forEach(([locale, loader]) => {
68
- it(`should load ${locale} content and follow slug rules`, async () => {
69
- const content = await loader();
70
- expect(content.slug).toBeDefined();
71
- expect(content.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
72
-
73
- if (locale === 'es') {
74
- const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas', 'calculadora-gestacion-mascotas', 'buscador-alimentos-toxicos-perros-gatos', 'calculadora-agua-diaria-perros-gatos'];
75
- expect(validSlugs).toContain(content.slug);
76
- }
77
- });
78
-
79
- it(`should have valid basic content in ${locale}`, async () => {
80
- const content = await loader();
81
- expect(content.title.length).toBeGreaterThan(0);
82
- expect(content.description.length).toBeGreaterThan(0);
83
- expect(Object.keys(content.ui).length).toBeGreaterThan(0);
84
- });
85
-
86
- it(`should have SEO content with > 300 words in ${locale}`, async () => {
87
- const content = await loader();
88
- const seoText = extractSeoText(content.seo);
89
- const wordCount = countWords(seoText);
90
- expect(wordCount).toBeGreaterThanOrEqual(300);
91
- });
92
- });
93
- });
94
- });
95
- });
96
- });
97
-
98
- describe('Library Registration', () => {
99
- it('should have 5 tools in ALL_TOOLS', () => {
100
- expect(ALL_TOOLS.length).toBe(5);
101
- });
102
-
103
- it('should have all tools in petsCategory', () => {
104
- expect(petsCategory.tools.length).toBe(5);
105
- ALL_TOOLS.forEach(({ entry }) => {
106
- const exists = petsCategory.tools.some((t: any) => t.id === entry.id);
107
- expect(exists).toBe(true);
108
- });
109
- });
110
- });
111
-
112
- describe('Binary Consistency', () => {
113
- ALL_TOOLS.forEach(({ entry }) => {
114
- it(`${entry.id} files should exist in the tool folder`, () => {
115
- const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
116
- const toolDir = path.join('src', 'tool', toolVarName);
117
-
118
- expect(fs.existsSync(path.join(toolDir, 'component.astro'))).toBe(true);
119
- expect(fs.existsSync(path.join(toolDir, 'seo.astro'))).toBe(true);
120
- expect(fs.existsSync(path.join(toolDir, 'bibliography.astro'))).toBe(true);
121
- expect(fs.existsSync(path.join(toolDir, 'index.ts'))).toBe(true);
122
- });
123
- });
124
- });
125
-
126
- describe('Bibliography Components Structure', () => {
127
- ALL_TOOLS.forEach(({ entry }) => {
128
- it(`${entry.id} should have a standard bibliography structure`, () => {
129
- const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
130
- const componentPath = path.join('src', 'tool', toolVarName, 'bibliography.astro');
131
- const content = fs.readFileSync(componentPath, 'utf-8');
132
-
133
- expect(content).toContain("import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared'");
134
- expect(content).toContain("import { bibliography } from './bibliography'");
135
- expect(content).toContain('<SharedBibliography links={bibliography} />');
136
- });
137
- });
138
- });
139
- });
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import { petsCategory } from '../data';
6
+ import type { ToolDefinition } from '../types';
7
+
8
+ function extractSeoText(sections: any[]): string {
9
+ return sections
10
+ .map((section) => extractSectionText(section))
11
+ .join(' ');
12
+ }
13
+
14
+ function extractHtmlOrText(section: any): string {
15
+ const hasContent = 'html' in section || 'text' in section;
16
+ const isNotTable = section.type !== 'table';
17
+ return hasContent && isNotTable ? (section.html || section.text || '') : '';
18
+ }
19
+
20
+ function extractListItems(section: any): string {
21
+ return 'items' in section && Array.isArray(section.items) ? section.items.join(' ') : '';
22
+ }
23
+
24
+ function extractTableRows(section: any): string {
25
+ return 'rows' in section && Array.isArray(section.rows) ? section.rows.flat().join(' ') : '';
26
+ }
27
+
28
+ function extractSectionText(section: any): string {
29
+ let text = '';
30
+ text += extractHtmlOrText(section);
31
+ text += extractListItems(section);
32
+ text += extractTableRows(section);
33
+ return text;
34
+ }
35
+
36
+ function countWords(text: string): number {
37
+ return text
38
+ .replace(/<[^>]*>/g, '')
39
+ .trim()
40
+ .split(/\s+/)
41
+ .filter((w) => w.length > 0).length;
42
+ }
43
+
44
+ describe('Tool Validation Suite', () => {
45
+ describe('Strict Tool Definition Validation', () => {
46
+ ALL_TOOLS.forEach((tool: ToolDefinition) => {
47
+ const { entry } = tool;
48
+
49
+ describe(`${entry.id} structure`, () => {
50
+ it('should fulfill ToolDefinition interface', () => {
51
+ expect(tool.entry).toBeDefined();
52
+ expect(tool.Component).toBeDefined();
53
+ expect(tool.SEOComponent).toBeDefined();
54
+ expect(tool.BibliographyComponent).toBeDefined();
55
+ });
56
+
57
+ it('should have valid ID in kebab-case', () => {
58
+ expect(entry.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
59
+ });
60
+
61
+ it('should have valid icons with bg and fg', () => {
62
+ expect(entry.icons.bg).toMatch(/^mdi:/);
63
+ expect(entry.icons.fg).toMatch(/^mdi:/);
64
+ });
65
+
66
+ describe('i18n Content Validation', () => {
67
+ Object.entries(entry.i18n).forEach(([locale, loader]) => {
68
+ it(`should load ${locale} content and follow slug rules`, async () => {
69
+ const content = await loader();
70
+ expect(content.slug).toBeDefined();
71
+ expect(content.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
72
+
73
+ if (locale === 'es') {
74
+ const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas', 'calculadora-gestacion-mascotas', 'buscador-alimentos-toxicos-perros-gatos', 'calculadora-agua-diaria-perros-gatos', 'planificador-dimensiones-transportin-mascotas'];
75
+ expect(validSlugs).toContain(content.slug);
76
+ }
77
+ });
78
+
79
+ it(`should have valid basic content in ${locale}`, async () => {
80
+ const content = await loader();
81
+ expect(content.title.length).toBeGreaterThan(0);
82
+ expect(content.description.length).toBeGreaterThan(0);
83
+ expect(Object.keys(content.ui).length).toBeGreaterThan(0);
84
+ });
85
+
86
+ it(`should have SEO content with > 300 words in ${locale}`, async () => {
87
+ const content = await loader();
88
+ const seoText = extractSeoText(content.seo);
89
+ const wordCount = countWords(seoText);
90
+ expect(wordCount).toBeGreaterThanOrEqual(300);
91
+ });
92
+ });
93
+ });
94
+ });
95
+ });
96
+ });
97
+
98
+ describe('Library Registration', () => {
99
+ it('should have 5 tools in ALL_TOOLS', () => {
100
+ expect(ALL_TOOLS.length).toBe(6);
101
+ });
102
+
103
+ it('should have all tools in petsCategory', () => {
104
+ expect(petsCategory.tools.length).toBe(6);
105
+ ALL_TOOLS.forEach(({ entry }) => {
106
+ const exists = petsCategory.tools.some((t: any) => t.id === entry.id);
107
+ expect(exists).toBe(true);
108
+ });
109
+ });
110
+ });
111
+
112
+ describe('Binary Consistency', () => {
113
+ ALL_TOOLS.forEach(({ entry }) => {
114
+ it(`${entry.id} files should exist in the tool folder`, () => {
115
+ const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
116
+ const toolDir = path.join('src', 'tool', toolVarName);
117
+
118
+ expect(fs.existsSync(path.join(toolDir, 'component.astro'))).toBe(true);
119
+ expect(fs.existsSync(path.join(toolDir, 'seo.astro'))).toBe(true);
120
+ expect(fs.existsSync(path.join(toolDir, 'bibliography.astro'))).toBe(true);
121
+ expect(fs.existsSync(path.join(toolDir, 'index.ts'))).toBe(true);
122
+ });
123
+ });
124
+ });
125
+
126
+ describe('Bibliography Components Structure', () => {
127
+ ALL_TOOLS.forEach(({ entry }) => {
128
+ it(`${entry.id} should have a standard bibliography structure`, () => {
129
+ const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
130
+ const componentPath = path.join('src', 'tool', toolVarName, 'bibliography.astro');
131
+ const content = fs.readFileSync(componentPath, 'utf-8');
132
+
133
+ expect(content).toContain("import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared'");
134
+ expect(content).toContain("import { bibliography } from './bibliography'");
135
+ expect(content).toContain('<SharedBibliography links={bibliography} />');
136
+ });
137
+ });
138
+ });
139
+ });
@@ -238,6 +238,7 @@ const { ui } = Astro.props;
238
238
  state: PetAgeState,
239
239
  els: Required<ReturnType<typeof getElements>>,
240
240
  birthYearInput: HTMLInputElement,
241
+ uiData: Record<string, string>,
241
242
  ) {
242
243
  els.shareBtn?.addEventListener("click", async () => {
243
244
  if (!state.birthYear) {
@@ -253,7 +254,7 @@ const { ui } = Astro.props;
253
254
  const url = generateShareUrl(state);
254
255
  try {
255
256
  await navigator.clipboard.writeText(url);
256
- if (els.shareText) els.shareText.textContent = "¡Copiado!";
257
+ if (els.shareText) els.shareText.textContent = uiData.shareSuccess;
257
258
  if (els.shareBtn) els.shareBtn.classList.add("age-btn-success");
258
259
  setTimeout(() => (window.location.href = url), 800);
259
260
  } catch {
@@ -292,7 +293,7 @@ const { ui } = Astro.props;
292
293
  setupPetTypeListener(state, els, onUpdate);
293
294
  setupSizeListener(state, els, onUpdate);
294
295
  setupBirthYearListener(state, birthYearInput, currentYear, onUpdate);
295
- setupShareListener(state, els, birthYearInput);
296
+ setupShareListener(state, els, birthYearInput, uiData);
296
297
 
297
298
  onUpdate();
298
299
  setTimeout(() => els.loader?.classList.add("hidden"), 50);
@@ -305,4 +306,3 @@ const { ui } = Astro.props;
305
306
  }
306
307
  document.addEventListener("astro:page-load", initPetAge);
307
308
  </script>
308
-