@jjlmoya/utils-pets 1.22.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.
- package/package.json +2 -2
- package/src/category/i18n/de.ts +1 -1
- package/src/category/i18n/en.ts +2 -2
- package/src/category/i18n/es.ts +1 -1
- package/src/category/i18n/fr.ts +1 -1
- package/src/category/i18n/pt.ts +1 -1
- package/src/category/i18n/tr.ts +1 -1
- package/src/category/index.ts +26 -26
- package/src/entries.ts +12 -12
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +74 -0
- package/src/tests/faq_count.test.ts +22 -22
- package/src/tests/locale_completeness.test.ts +30 -30
- package/src/tests/no_h1_in_components.test.ts +1 -1
- package/src/tests/pet_logic.test.ts +49 -0
- package/src/tests/seo_length.test.ts +54 -54
- package/src/tests/seo_parity.test.ts +2 -2
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/spanish_leakage.test.ts +175 -175
- package/src/tests/tool_validation.test.ts +137 -137
- package/src/tool/petAge/component.astro +3 -3
- package/src/tool/petAge/i18n/de.ts +184 -184
- package/src/tool/petAge/i18n/en.ts +184 -184
- package/src/tool/petAge/i18n/es.ts +184 -184
- package/src/tool/petAge/i18n/fr.ts +184 -184
- package/src/tool/petAge/i18n/id.ts +184 -184
- package/src/tool/petAge/i18n/it.ts +184 -184
- package/src/tool/petAge/i18n/ja.ts +184 -184
- package/src/tool/petAge/i18n/ko.ts +184 -184
- package/src/tool/petAge/i18n/nl.ts +184 -184
- package/src/tool/petAge/i18n/pl.ts +184 -184
- package/src/tool/petAge/i18n/pt.ts +184 -184
- package/src/tool/petAge/i18n/ru.ts +184 -184
- package/src/tool/petAge/i18n/sv.ts +184 -184
- package/src/tool/petAge/i18n/tr.ts +185 -185
- package/src/tool/petAge/i18n/zh.ts +184 -184
- package/src/tool/petAge/seo.astro +15 -15
- package/src/tool/petGestation/i18n/de.ts +0 -2
- package/src/tool/petGestation/i18n/en.ts +0 -2
- package/src/tool/petGestation/i18n/es.ts +0 -2
- package/src/tool/petGestation/i18n/fr.ts +0 -2
- package/src/tool/petGestation/i18n/id.ts +0 -2
- package/src/tool/petGestation/i18n/it.ts +0 -2
- package/src/tool/petGestation/i18n/ja.ts +0 -2
- package/src/tool/petGestation/i18n/ko.ts +0 -2
- package/src/tool/petGestation/i18n/nl.ts +0 -2
- package/src/tool/petGestation/i18n/pl.ts +0 -2
- package/src/tool/petGestation/i18n/pt.ts +0 -2
- package/src/tool/petGestation/i18n/ru.ts +0 -2
- package/src/tool/petGestation/i18n/sv.ts +0 -2
- package/src/tool/petGestation/i18n/tr.ts +0 -2
- package/src/tool/petGestation/i18n/zh.ts +0 -2
- package/src/tool/petGestation/ui.ts +0 -2
- package/src/tool/petRation/i18n/de.ts +181 -181
- package/src/tool/petRation/i18n/en.ts +181 -181
- package/src/tool/petRation/i18n/es.ts +181 -181
- package/src/tool/petRation/i18n/fr.ts +181 -181
- package/src/tool/petRation/i18n/id.ts +181 -181
- package/src/tool/petRation/i18n/it.ts +181 -181
- package/src/tool/petRation/i18n/ja.ts +181 -181
- package/src/tool/petRation/i18n/ko.ts +181 -181
- package/src/tool/petRation/i18n/nl.ts +181 -181
- package/src/tool/petRation/i18n/pl.ts +181 -181
- package/src/tool/petRation/i18n/pt.ts +181 -181
- package/src/tool/petRation/i18n/ru.ts +181 -181
- package/src/tool/petRation/i18n/sv.ts +181 -181
- package/src/tool/petRation/i18n/tr.ts +181 -181
- package/src/tool/petRation/i18n/zh.ts +181 -181
- package/src/tool/petRation/seo.astro +15 -15
- package/src/tool/petToxicity/i18n/de.ts +0 -2
- package/src/tool/petToxicity/i18n/en.ts +0 -2
- package/src/tool/petToxicity/i18n/es.ts +2 -4
- package/src/tool/petToxicity/i18n/fr.ts +0 -2
- package/src/tool/petToxicity/i18n/id.ts +0 -2
- package/src/tool/petToxicity/i18n/it.ts +0 -2
- package/src/tool/petToxicity/i18n/ja.ts +0 -2
- package/src/tool/petToxicity/i18n/ko.ts +0 -2
- package/src/tool/petToxicity/i18n/nl.ts +0 -2
- package/src/tool/petToxicity/i18n/pl.ts +0 -2
- package/src/tool/petToxicity/i18n/pt.ts +0 -2
- package/src/tool/petToxicity/i18n/ru.ts +0 -2
- package/src/tool/petToxicity/i18n/sv.ts +0 -2
- package/src/tool/petToxicity/i18n/tr.ts +0 -2
- package/src/tool/petToxicity/i18n/zh.ts +0 -2
- package/src/tool/petToxicity/ui.ts +0 -2
- package/src/tool/petWaterIntake/i18n/de.ts +0 -15
- package/src/tool/petWaterIntake/i18n/en.ts +0 -15
- package/src/tool/petWaterIntake/i18n/es.ts +2 -17
- package/src/tool/petWaterIntake/i18n/fr.ts +0 -15
- package/src/tool/petWaterIntake/i18n/id.ts +0 -15
- package/src/tool/petWaterIntake/i18n/it.ts +0 -15
- package/src/tool/petWaterIntake/i18n/ja.ts +0 -15
- package/src/tool/petWaterIntake/i18n/ko.ts +0 -15
- package/src/tool/petWaterIntake/i18n/nl.ts +0 -15
- package/src/tool/petWaterIntake/i18n/pl.ts +0 -15
- package/src/tool/petWaterIntake/i18n/pt.ts +0 -15
- package/src/tool/petWaterIntake/i18n/ru.ts +0 -15
- package/src/tool/petWaterIntake/i18n/sv.ts +0 -15
- package/src/tool/petWaterIntake/i18n/tr.ts +0 -15
- package/src/tool/petWaterIntake/i18n/zh.ts +0 -15
- package/src/tool/petWaterIntake/ui.ts +0 -15
- package/src/tools.ts +19 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-pets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"lint": "eslint src/ --max-warnings 0 && stylelint \"src/**/*.{css,astro}\"",
|
|
28
28
|
"check": "astro check",
|
|
29
29
|
"type-check": "astro check",
|
|
30
|
-
"test": "vitest run",
|
|
30
|
+
"test": "vitest run --reporter=verbose --testTimeout=30000",
|
|
31
31
|
"preversion": "npm run lint && npm run test && npm run build",
|
|
32
32
|
"postversion": "git push && git push --tags",
|
|
33
33
|
"patch": "npm version patch",
|
package/src/category/i18n/de.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
|
|
|
3
3
|
export const content: CategoryLocaleContent = {
|
|
4
4
|
slug: 'haustiere',
|
|
5
5
|
title: 'Haustier Tools',
|
|
6
|
-
description: 'Kostenlose Rechner
|
|
6
|
+
description: 'Kostenlose Rechner für Haustierbesitzer: Hunde- und Katzenalter sowie tägliche Futterrationen einfach einschätzen.',
|
|
7
7
|
seo: [
|
|
8
8
|
{ type: 'title', text: 'Tierernährung und Wohlbefinden: Wissenschaft im Dienste Ihres Haustieres', level: 2 },
|
|
9
9
|
{
|
package/src/category/i18n/en.ts
CHANGED
|
@@ -2,13 +2,13 @@ import type { CategoryLocaleContent } from '../../types';
|
|
|
2
2
|
|
|
3
3
|
export const content: CategoryLocaleContent = {
|
|
4
4
|
slug: 'pets',
|
|
5
|
-
title: 'Pet
|
|
5
|
+
title: 'Pet Care Calculators',
|
|
6
6
|
description: 'Free calculators and utilities for pet owners. Find out your dog or cat\'s real age and calculate their daily ration with veterinary precision.',
|
|
7
7
|
seo: [
|
|
8
8
|
{ type: 'title', text: 'Animal Nutrition and Wellbeing: Science in the Service of Your Pet', level: 2 },
|
|
9
9
|
{
|
|
10
10
|
type: 'paragraph',
|
|
11
|
-
html: 'Caring for a dog, cat, or rabbit
|
|
11
|
+
html: 'Caring for a dog, cat, or rabbit involves understanding biology, aging and energy needs. Our <strong>free veterinary calculators</strong> use published formulas to support informed decisions about your companion\'s health.',
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
type: 'paragraph',
|
package/src/category/i18n/es.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const content: CategoryLocaleContent = {
|
|
|
8
8
|
{ type: 'title', text: 'Nutrición y Bienestar Animal: Ciencia al Servicio de tu Mascota', level: 2 },
|
|
9
9
|
{
|
|
10
10
|
type: 'paragraph',
|
|
11
|
-
html: 'Cuidar
|
|
11
|
+
html: 'Cuidar de un perro, gato o conejo implica entender su biología, envejecimiento y necesidades energéticas. Nuestras <strong>calculadoras veterinarias gratuitas</strong> usan fórmulas publicadas para orientar decisiones sobre su salud.',
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
type: 'paragraph',
|
package/src/category/i18n/fr.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const content: CategoryLocaleContent = {
|
|
|
8
8
|
{ type: 'title', text: 'Nutrition et Bien-être Animal: La Science au Service de votre Animal', level: 2 },
|
|
9
9
|
{
|
|
10
10
|
type: 'paragraph',
|
|
11
|
-
html: 'Prendre soin d\'un chien, d\'un chat ou d\'un lapin
|
|
11
|
+
html: 'Prendre soin d\'un chien, d\'un chat ou d\'un lapin implique de comprendre sa biologie, son vieillissement et ses besoins énergétiques. Nos <strong>calculatrices vétérinaires gratuites</strong> utilisent des formules publiées pour éclairer vos décisions.',
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
type: 'paragraph',
|
package/src/category/i18n/pt.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const content: CategoryLocaleContent = {
|
|
|
8
8
|
{ type: 'title', text: 'Nutrição e Bem-estar Animal: Ciência ao Serviço do Seu Animal', level: 2 },
|
|
9
9
|
{
|
|
10
10
|
type: 'paragraph',
|
|
11
|
-
html: 'Cuidar de um cão, gato ou coelho
|
|
11
|
+
html: 'Cuidar de um cão, gato ou coelho implica compreender a sua biologia, envelhecimento e necessidades energéticas. As nossas <strong>calculadoras veterinárias gratuitas</strong> usam fórmulas publicadas para apoiar decisões informadas.',
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
type: 'paragraph',
|
package/src/category/i18n/tr.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
|
|
|
3
3
|
export const content: CategoryLocaleContent = {
|
|
4
4
|
slug: 'evcil-hayvanlar',
|
|
5
5
|
title: 'Evcil Hayvan Araçları',
|
|
6
|
-
description: 'Evcil hayvan sahipleri için ücretsiz hesaplayıcılar ve
|
|
6
|
+
description: 'Evcil hayvan sahipleri için ücretsiz hesaplayıcılar: köpek ve kedi yaşı ile günlük mama miktarını tahmin edin.',
|
|
7
7
|
seo: [
|
|
8
8
|
{ type: 'title', text: 'Hayvan Beslenmesi ve Refahı: Evcil Hayvanınızın Hizmetinde Bilim', level: 2 },
|
|
9
9
|
{
|
package/src/category/index.ts
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
import type { PetCategoryEntry } from '../types';
|
|
2
|
-
import { petAge } from '../tool/petAge/entry';
|
|
3
|
-
import { petRation } from '../tool/petRation/entry';
|
|
4
|
-
import { petGestation } from '../tool/petGestation/entry';
|
|
5
|
-
import { petToxicity } from '../tool/petToxicity/entry';
|
|
1
|
+
import type { PetCategoryEntry } from '../types';
|
|
2
|
+
import { petAge } from '../tool/petAge/entry';
|
|
3
|
+
import { petRation } from '../tool/petRation/entry';
|
|
4
|
+
import { petGestation } from '../tool/petGestation/entry';
|
|
5
|
+
import { petToxicity } from '../tool/petToxicity/entry';
|
|
6
6
|
import { petWaterIntake } from '../tool/petWaterIntake/entry';
|
|
7
7
|
import { petCarrierCrateSizePlanner } from '../tool/petCarrierCrateSizePlanner/entry';
|
|
8
|
-
|
|
9
|
-
export const petsCategory: PetCategoryEntry = {
|
|
10
|
-
icon: 'mdi:paw',
|
|
8
|
+
|
|
9
|
+
export const petsCategory: PetCategoryEntry = {
|
|
10
|
+
icon: 'mdi:paw',
|
|
11
11
|
tools: [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner],
|
|
12
|
-
i18n: {
|
|
13
|
-
en: () => import('./i18n/en').then((m) => m.content),
|
|
14
|
-
es: () => import('./i18n/es').then((m) => m.content),
|
|
15
|
-
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
16
|
-
de: () => import('./i18n/de').then((m) => m.content),
|
|
17
|
-
id: () => import('./i18n/id').then((m) => m.content),
|
|
18
|
-
it: () => import('./i18n/it').then((m) => m.content),
|
|
19
|
-
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
20
|
-
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
21
|
-
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
22
|
-
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
23
|
-
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
24
|
-
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
25
|
-
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
26
|
-
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
27
|
-
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
28
|
-
},
|
|
29
|
-
};
|
|
12
|
+
i18n: {
|
|
13
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
14
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
15
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
16
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
17
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
18
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
19
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
20
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
21
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
22
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
23
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
24
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
25
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
26
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
27
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
28
|
+
},
|
|
29
|
+
};
|
package/src/entries.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
export { petAge } from './tool/petAge/entry';
|
|
2
|
-
export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge/entry';
|
|
3
|
-
export { petRation } from './tool/petRation/entry';
|
|
4
|
-
export type { PetRationUI, PetRationLocaleContent } from './tool/petRation/entry';
|
|
5
|
-
export { petToxicity } from './tool/petToxicity/entry';
|
|
6
|
-
export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity/entry';
|
|
7
|
-
export { petWaterIntake } from './tool/petWaterIntake/entry';
|
|
1
|
+
export { petAge } from './tool/petAge/entry';
|
|
2
|
+
export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge/entry';
|
|
3
|
+
export { petRation } from './tool/petRation/entry';
|
|
4
|
+
export type { PetRationUI, PetRationLocaleContent } from './tool/petRation/entry';
|
|
5
|
+
export { petToxicity } from './tool/petToxicity/entry';
|
|
6
|
+
export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity/entry';
|
|
7
|
+
export { petWaterIntake } from './tool/petWaterIntake/entry';
|
|
8
8
|
export type { PetWaterIntakeUI, PetWaterIntakeLocaleContent } from './tool/petWaterIntake/entry';
|
|
9
9
|
export { petCarrierCrateSizePlanner } from './tool/petCarrierCrateSizePlanner/entry';
|
|
10
10
|
export type { PetCarrierCrateSizePlannerUI, PetCarrierCrateSizePlannerLocaleContent } from './tool/petCarrierCrateSizePlanner/entry';
|
|
11
|
-
export { petsCategory } from './category';
|
|
12
|
-
import { petAge } from './tool/petAge/entry';
|
|
13
|
-
import { petRation } from './tool/petRation/entry';
|
|
14
|
-
import { petGestation } from './tool/petGestation/entry';
|
|
15
|
-
import { petToxicity } from './tool/petToxicity/entry';
|
|
11
|
+
export { petsCategory } from './category';
|
|
12
|
+
import { petAge } from './tool/petAge/entry';
|
|
13
|
+
import { petRation } from './tool/petRation/entry';
|
|
14
|
+
import { petGestation } from './tool/petGestation/entry';
|
|
15
|
+
import { petToxicity } from './tool/petToxicity/entry';
|
|
16
16
|
import { petWaterIntake } from './tool/petWaterIntake/entry';
|
|
17
17
|
import { petCarrierCrateSizePlanner } from './tool/petCarrierCrateSizePlanner/entry';
|
|
18
18
|
export const ALL_ENTRIES = [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner];
|
|
@@ -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,74 @@
|
|
|
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
|
+
check(content.seo.some((section) => section.type === 'paragraph'), 'SEO needs visible explanatory copy');
|
|
38
|
+
|
|
39
|
+
const seoText = textFrom(content.seo).replace(/\s+/g, ' ').trim();
|
|
40
|
+
const minimumSeoLength = COMPACT_LOCALES.has(locale) ? 120 : 240;
|
|
41
|
+
check(seoText.length >= minimumSeoLength, 'SEO copy must contain the minimum visible copy for its script');
|
|
42
|
+
check(content.seo.filter((section) => section.type === 'title').length >= 1, 'SEO needs a section heading');
|
|
43
|
+
check(!/[�]/u.test(seoText), 'SEO contains replacement characters');
|
|
44
|
+
check(!/ {2,}/u.test(seoText), 'SEO contains duplicated whitespace');
|
|
45
|
+
check(!STRONG_CLAIM.test(seoText), 'SEO contains an unsupported strong claim');
|
|
46
|
+
|
|
47
|
+
check(content.slug.trim().length > 0 && /^[a-z0-9-]+$/u.test(content.slug), 'slug must be a non-empty URL-safe value');
|
|
48
|
+
return failures;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('Category SEO quality contract', () => {
|
|
52
|
+
it('has one content file for every configured locale', () => {
|
|
53
|
+
const contents = getContents();
|
|
54
|
+
expect([...contents.keys()].sort()).toEqual([...EXPECTED_LOCALES].sort());
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('keeps metadata, SEO structure and copy quality above the minimum in every locale', () => {
|
|
58
|
+
const contents = getContents();
|
|
59
|
+
const english = contents.get('en');
|
|
60
|
+
expect(english, 'English category content is required as the structural reference').toBeDefined();
|
|
61
|
+
|
|
62
|
+
const failures = EXPECTED_LOCALES.flatMap((locale) => {
|
|
63
|
+
const content = contents.get(locale);
|
|
64
|
+
return content ? validateContent(locale, content) : [];
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
for (const locale of EXPECTED_LOCALES) {
|
|
68
|
+
const content = contents.get(locale);
|
|
69
|
+
if (!content) failures.push(`${locale}: category content is missing`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
expect(failures, 'category SEO quality failures').toEqual([]);
|
|
73
|
+
}, 30000);
|
|
74
|
+
});
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import * as DATA from '../data';
|
|
3
|
-
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as DATA from '../data';
|
|
3
|
+
|
|
4
4
|
const TOOLS = [DATA.petAge, DATA.petRation, DATA.petGestation, DATA.petToxicity];
|
|
5
|
-
|
|
6
|
-
describe('FAQ Content Validation', () => {
|
|
7
|
-
TOOLS.forEach((entry) => {
|
|
8
|
-
describe(`Tool: ${entry.id}`, () => {
|
|
9
|
-
Object.keys(entry.i18n).forEach((locale) => {
|
|
10
|
-
it(`${locale}: should have at least 3 FAQ items`, async () => {
|
|
11
|
-
const loader = (entry.i18n as any)[locale];
|
|
12
|
-
const content = await loader();
|
|
13
|
-
|
|
14
|
-
if (!content.faq) {
|
|
15
|
-
throw new Error(`Tool ${entry.id} (${locale}) is missing the 'faq' property entirely.`);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
expect(content.faq.length, `Tool ${entry.id} (${locale}) has only ${content.faq.length} FAQs, minimum 3 required.`).toBeGreaterThanOrEqual(3);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
});
|
|
5
|
+
|
|
6
|
+
describe('FAQ Content Validation', () => {
|
|
7
|
+
TOOLS.forEach((entry) => {
|
|
8
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
9
|
+
Object.keys(entry.i18n).forEach((locale) => {
|
|
10
|
+
it(`${locale}: should have at least 3 FAQ items`, async () => {
|
|
11
|
+
const loader = (entry.i18n as any)[locale];
|
|
12
|
+
const content = await loader();
|
|
13
|
+
|
|
14
|
+
if (!content.faq) {
|
|
15
|
+
throw new Error(`Tool ${entry.id} (${locale}) is missing the 'faq' property entirely.`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
expect(content.faq.length, `Tool ${entry.id} (${locale}) has only ${content.faq.length} FAQs, minimum 3 required.`).toBeGreaterThanOrEqual(3);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { ALL_TOOLS } from '../tools';
|
|
3
|
-
import type { ToolLocaleContent } from '../types';
|
|
4
|
-
|
|
5
|
-
describe('Locale Completeness Validation', () => {
|
|
6
|
-
ALL_TOOLS.forEach((tool) => {
|
|
7
|
-
describe(`Tool: ${tool.entry.id}`, () => {
|
|
8
|
-
Object.keys(tool.entry.i18n).forEach((locale) => {
|
|
9
|
-
describe(`Locale: ${locale}`, () => {
|
|
10
|
-
it('faq should be defined', async () => {
|
|
11
|
-
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
12
|
-
const content = (await loader?.()) as ToolLocaleContent;
|
|
13
|
-
|
|
14
|
-
expect(Array.isArray(content.faq)).toBe(true);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('bibliography should be defined', async () => {
|
|
18
|
-
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
19
|
-
const content = (await loader?.()) as ToolLocaleContent;
|
|
20
|
-
|
|
21
|
-
expect(Array.isArray(content.bibliography)).toBe(true);
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('all tools registered', () => {
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import type { ToolLocaleContent } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('Locale Completeness Validation', () => {
|
|
6
|
+
ALL_TOOLS.forEach((tool) => {
|
|
7
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
8
|
+
Object.keys(tool.entry.i18n).forEach((locale) => {
|
|
9
|
+
describe(`Locale: ${locale}`, () => {
|
|
10
|
+
it('faq should be defined', async () => {
|
|
11
|
+
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
12
|
+
const content = (await loader?.()) as ToolLocaleContent;
|
|
13
|
+
|
|
14
|
+
expect(Array.isArray(content.faq)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('bibliography should be defined', async () => {
|
|
18
|
+
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
19
|
+
const content = (await loader?.()) as ToolLocaleContent;
|
|
20
|
+
|
|
21
|
+
expect(Array.isArray(content.bibliography)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('all tools registered', () => {
|
|
29
29
|
expect(ALL_TOOLS.length).toBe(6);
|
|
30
|
-
});
|
|
31
|
-
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
calculateCatHumanAge,
|
|
4
|
+
calculateDogHumanAge,
|
|
5
|
+
calculatePetHumanAge,
|
|
6
|
+
getCatStageKey,
|
|
7
|
+
getDogMilestone,
|
|
8
|
+
} from '../tool/petAge/logic';
|
|
9
|
+
import {
|
|
10
|
+
calculateDER,
|
|
11
|
+
calculateRation,
|
|
12
|
+
calculateRER,
|
|
13
|
+
getStatusKey,
|
|
14
|
+
} from '../tool/petRation/logic';
|
|
15
|
+
|
|
16
|
+
describe('pet age reference calculations', () => {
|
|
17
|
+
it('uses the documented first-year and second-year anchors', () => {
|
|
18
|
+
expect(calculateDogHumanAge(1, 'small')).toBe(15);
|
|
19
|
+
expect(calculateDogHumanAge(2, 'small')).toBe(24);
|
|
20
|
+
expect(calculateCatHumanAge(1)).toBe(15);
|
|
21
|
+
expect(calculateCatHumanAge(2)).toBe(24);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('handles zero and size-specific adult progression', () => {
|
|
25
|
+
expect(calculatePetHumanAge('dog', 0, 'giant')).toBe(0);
|
|
26
|
+
expect(calculatePetHumanAge('cat', 0)).toBe(0);
|
|
27
|
+
expect(calculateDogHumanAge(3, 'small')).toBe(29);
|
|
28
|
+
expect(calculateDogHumanAge(3, 'giant')).toBe(32);
|
|
29
|
+
expect(getCatStageKey(80)).toBe('stageCatVenerable');
|
|
30
|
+
expect(getDogMilestone(8)).toBe('Chequeo Geriátrico Semestral');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('pet ration reference calculations', () => {
|
|
35
|
+
it('calculates RER and DER using the selected profile', () => {
|
|
36
|
+
expect(calculateRER(10)).toBeCloseTo(393.64, 1);
|
|
37
|
+
expect(calculateDER(400, 'dog', 'adult', 'low')).toBe(480);
|
|
38
|
+
expect(calculateDER(400, 'dog', 'adult', 'high')).toBe(800);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('keeps dry and wet ration outputs non-negative and preserves the split', () => {
|
|
42
|
+
const ration = calculateRation(700, 0.4);
|
|
43
|
+
expect(ration.dryGrams).toBe(120);
|
|
44
|
+
expect(ration.wetGrams).toBe(280);
|
|
45
|
+
expect(calculateRation(0, 0)).toEqual({ dryGrams: 0, wetGrams: 0 });
|
|
46
|
+
expect(getStatusKey('dog', 'puppy', 5)).toBe('statusPuppy');
|
|
47
|
+
expect(getStatusKey('dog', 'adult', 50)).toBe('statusLargeDog');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import * as DATA from '../data';
|
|
3
|
-
|
|
4
|
-
const ENTRIES = [
|
|
5
|
-
{ id: 'petAge', i18n: DATA.petAge.i18n },
|
|
6
|
-
{ id: 'petRation', i18n: DATA.petRation.i18n },
|
|
7
|
-
{ id: 'petsCategory', i18n: DATA.petsCategory.i18n },
|
|
8
|
-
];
|
|
9
|
-
|
|
10
|
-
describe('SEO Content Length Validation', () => {
|
|
11
|
-
ENTRIES.forEach((entry) => {
|
|
12
|
-
describe(`Tool: ${entry.id}`, () => {
|
|
13
|
-
Object.keys(entry.i18n).forEach((locale) => {
|
|
14
|
-
it(`${locale}: SEO section should contain between 400 and 900 words`, async () => {
|
|
15
|
-
const loader = (entry.i18n as any)[locale];
|
|
16
|
-
const content = await loader();
|
|
17
|
-
if (!content.seo) return;
|
|
18
|
-
|
|
19
|
-
let combinedText = '';
|
|
20
|
-
content.seo.forEach((section: any) => {
|
|
21
|
-
if (section.text) combinedText += section.text + ' ';
|
|
22
|
-
if (section.html) combinedText += section.html + ' ';
|
|
23
|
-
if (section.title) combinedText += section.title + ' ';
|
|
24
|
-
if (section.items) {
|
|
25
|
-
section.items.forEach((item: any) => {
|
|
26
|
-
if (typeof item === 'string') combinedText += item + ' ';
|
|
27
|
-
else {
|
|
28
|
-
if (item.label) combinedText += item.label + ' ';
|
|
29
|
-
if (item.value) combinedText += item.value + ' ';
|
|
30
|
-
if (item.term) combinedText += item.term + ' ';
|
|
31
|
-
if (item.definition) combinedText += item.definition + ' ';
|
|
32
|
-
if (item.pro) combinedText += item.pro + ' ';
|
|
33
|
-
if (item.con) combinedText += item.con + ' ';
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
if (section.headers) combinedText += section.headers.join(' ') + ' ';
|
|
38
|
-
if (section.rows) {
|
|
39
|
-
section.rows.forEach((row: string[]) => {
|
|
40
|
-
combinedText += row.join(' ') + ' ';
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
const cleanText = combinedText.replace(/<[^>]*>?/gm, ' ').trim();
|
|
46
|
-
const words = cleanText.split(/\s+/).filter((w: string) => w.length > 0);
|
|
47
|
-
|
|
48
|
-
expect(words.length, `Tool ${entry.id} (${locale}) has ${words.length} words. Target: 400-900.`).toBeGreaterThanOrEqual(400);
|
|
49
|
-
expect(words.length, `Tool ${entry.id} (${locale}) has ${words.length} words. Target: 400-900.`).toBeLessThanOrEqual(900);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
});
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as DATA from '../data';
|
|
3
|
+
|
|
4
|
+
const ENTRIES = [
|
|
5
|
+
{ id: 'petAge', i18n: DATA.petAge.i18n },
|
|
6
|
+
{ id: 'petRation', i18n: DATA.petRation.i18n },
|
|
7
|
+
{ id: 'petsCategory', i18n: DATA.petsCategory.i18n },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
describe('SEO Content Length Validation', () => {
|
|
11
|
+
ENTRIES.forEach((entry) => {
|
|
12
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
13
|
+
Object.keys(entry.i18n).forEach((locale) => {
|
|
14
|
+
it(`${locale}: SEO section should contain between 400 and 900 words`, async () => {
|
|
15
|
+
const loader = (entry.i18n as any)[locale];
|
|
16
|
+
const content = await loader();
|
|
17
|
+
if (!content.seo) return;
|
|
18
|
+
|
|
19
|
+
let combinedText = '';
|
|
20
|
+
content.seo.forEach((section: any) => {
|
|
21
|
+
if (section.text) combinedText += section.text + ' ';
|
|
22
|
+
if (section.html) combinedText += section.html + ' ';
|
|
23
|
+
if (section.title) combinedText += section.title + ' ';
|
|
24
|
+
if (section.items) {
|
|
25
|
+
section.items.forEach((item: any) => {
|
|
26
|
+
if (typeof item === 'string') combinedText += item + ' ';
|
|
27
|
+
else {
|
|
28
|
+
if (item.label) combinedText += item.label + ' ';
|
|
29
|
+
if (item.value) combinedText += item.value + ' ';
|
|
30
|
+
if (item.term) combinedText += item.term + ' ';
|
|
31
|
+
if (item.definition) combinedText += item.definition + ' ';
|
|
32
|
+
if (item.pro) combinedText += item.pro + ' ';
|
|
33
|
+
if (item.con) combinedText += item.con + ' ';
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (section.headers) combinedText += section.headers.join(' ') + ' ';
|
|
38
|
+
if (section.rows) {
|
|
39
|
+
section.rows.forEach((row: string[]) => {
|
|
40
|
+
combinedText += row.join(' ') + ' ';
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const cleanText = combinedText.replace(/<[^>]*>?/gm, ' ').trim();
|
|
46
|
+
const words = cleanText.split(/\s+/).filter((w: string) => w.length > 0);
|
|
47
|
+
|
|
48
|
+
expect(words.length, `Tool ${entry.id} (${locale}) has ${words.length} words. Target: 400-900.`).toBeGreaterThanOrEqual(400);
|
|
49
|
+
expect(words.length, `Tool ${entry.id} (${locale}) has ${words.length} words. Target: 400-900.`).toBeLessThanOrEqual(900);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -27,7 +27,7 @@ async function verifyLocaleParity(
|
|
|
27
27
|
expect(
|
|
28
28
|
locSeoCount,
|
|
29
29
|
`Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
|
|
30
|
-
).
|
|
30
|
+
).toBeGreaterThanOrEqual(expected.seo);
|
|
31
31
|
expect(
|
|
32
32
|
locFaqCount,
|
|
33
33
|
`Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
|
|
@@ -41,7 +41,7 @@ async function verifyLocaleParity(
|
|
|
41
41
|
describe('SEO & i18n Structural Parity Suite', () => {
|
|
42
42
|
ALL_ENTRIES.forEach((entry) => {
|
|
43
43
|
describe(`Tool: ${entry.id}`, () => {
|
|
44
|
-
it('all 15 locales should
|
|
44
|
+
it('all 15 locales should provide at least the complete English SEO section set', async () => {
|
|
45
45
|
const enContent = await entry.i18n.en?.();
|
|
46
46
|
expect(enContent).toBeDefined();
|
|
47
47
|
const expected: ExpectedCounts = {
|