@jjlmoya/utils-pets 1.29.0 → 1.30.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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/data.ts +2 -0
  4. package/src/entries.ts +4 -1
  5. package/src/index.ts +1 -0
  6. package/src/tests/i18n_coverage.test.ts +1 -1
  7. package/src/tests/locale_completeness.test.ts +1 -1
  8. package/src/tests/mfe_assets_contract.test.ts +54 -0
  9. package/src/tests/tool_validation.test.ts +3 -3
  10. package/src/tool/petCollarSizeCalculator/bibliography.astro +6 -0
  11. package/src/tool/petCollarSizeCalculator/bibliography.ts +12 -0
  12. package/src/tool/petCollarSizeCalculator/component.astro +65 -0
  13. package/src/tool/petCollarSizeCalculator/controller.ts +160 -0
  14. package/src/tool/petCollarSizeCalculator/dom-views.ts +111 -0
  15. package/src/tool/petCollarSizeCalculator/entry.ts +32 -0
  16. package/src/tool/petCollarSizeCalculator/evaluator.ts +18 -0
  17. package/src/tool/petCollarSizeCalculator/i18n/de.ts +44 -0
  18. package/src/tool/petCollarSizeCalculator/i18n/en.ts +153 -0
  19. package/src/tool/petCollarSizeCalculator/i18n/es.ts +44 -0
  20. package/src/tool/petCollarSizeCalculator/i18n/fr.ts +44 -0
  21. package/src/tool/petCollarSizeCalculator/i18n/id.ts +44 -0
  22. package/src/tool/petCollarSizeCalculator/i18n/it.ts +44 -0
  23. package/src/tool/petCollarSizeCalculator/i18n/ja.ts +44 -0
  24. package/src/tool/petCollarSizeCalculator/i18n/ko.ts +44 -0
  25. package/src/tool/petCollarSizeCalculator/i18n/nl.ts +44 -0
  26. package/src/tool/petCollarSizeCalculator/i18n/pl.ts +44 -0
  27. package/src/tool/petCollarSizeCalculator/i18n/pt.ts +44 -0
  28. package/src/tool/petCollarSizeCalculator/i18n/ru.ts +44 -0
  29. package/src/tool/petCollarSizeCalculator/i18n/sv.ts +44 -0
  30. package/src/tool/petCollarSizeCalculator/i18n/tr.ts +44 -0
  31. package/src/tool/petCollarSizeCalculator/i18n/zh.ts +44 -0
  32. package/src/tool/petCollarSizeCalculator/index.ts +11 -0
  33. package/src/tool/petCollarSizeCalculator/logic.test.ts +63 -0
  34. package/src/tool/petCollarSizeCalculator/logic.ts +72 -0
  35. package/src/tool/petCollarSizeCalculator/pet-collar-size-calculator.css +631 -0
  36. package/src/tool/petCollarSizeCalculator/seo.astro +14 -0
  37. package/src/tool/petCollarSizeCalculator/storage.ts +29 -0
  38. package/src/tool/petCollarSizeCalculator/ui.ts +33 -0
  39. package/src/tools.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-pets",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -6,10 +6,11 @@ import { petToxicity } from '../tool/petToxicity/entry';
6
6
  import { petWaterIntake } from '../tool/petWaterIntake/entry';
7
7
  import { petCarrierCrateSizePlanner } from '../tool/petCarrierCrateSizePlanner/entry';
8
8
  import { petMedicationSchedulePlanner } from '../tool/petMedicationSchedulePlanner/entry';
9
+ import { petCollarSizeCalculator } from '../tool/petCollarSizeCalculator/entry';
9
10
 
10
11
  export const petsCategory: PetCategoryEntry = {
11
12
  icon: 'mdi:paw',
12
- tools: [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner, petMedicationSchedulePlanner],
13
+ tools: [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner, petMedicationSchedulePlanner, petCollarSizeCalculator],
13
14
  i18n: {
14
15
  en: () => import('./i18n/en').then((m) => m.content),
15
16
  es: () => import('./i18n/es').then((m) => m.content),
package/src/data.ts CHANGED
@@ -4,12 +4,14 @@ export { petRation } from './tool/petRation';
4
4
  export { petGestation } from './tool/petGestation';
5
5
  export { petToxicity } from './tool/petToxicity';
6
6
  export { petMedicationSchedulePlanner } from './tool/petMedicationSchedulePlanner';
7
+ export { petCollarSizeCalculator } from './tool/petCollarSizeCalculator';
7
8
 
8
9
  export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge';
9
10
  export type { PetRationUI, PetRationLocaleContent } from './tool/petRation';
10
11
  export type { PetGestationUI, PetGestationLocaleContent } from './tool/petGestation';
11
12
  export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity';
12
13
  export type { PetMedicationSchedulePlannerUI, PetMedicationSchedulePlannerLocaleContent } from './tool/petMedicationSchedulePlanner';
14
+ export type { PetCollarSizeCalculatorUI, PetCollarSizeCalculatorLocaleContent } from './tool/petCollarSizeCalculator';
13
15
 
14
16
  export type {
15
17
  KnownLocale,
package/src/entries.ts CHANGED
@@ -10,6 +10,8 @@ export { petCarrierCrateSizePlanner } from './tool/petCarrierCrateSizePlanner/en
10
10
  export type { PetCarrierCrateSizePlannerUI, PetCarrierCrateSizePlannerLocaleContent } from './tool/petCarrierCrateSizePlanner/entry';
11
11
  export { petMedicationSchedulePlanner } from './tool/petMedicationSchedulePlanner/entry';
12
12
  export type { PetMedicationSchedulePlannerUI, PetMedicationSchedulePlannerLocaleContent } from './tool/petMedicationSchedulePlanner/entry';
13
+ export { petCollarSizeCalculator } from './tool/petCollarSizeCalculator/entry';
14
+ export type { PetCollarSizeCalculatorUI, PetCollarSizeCalculatorLocaleContent } from './tool/petCollarSizeCalculator/entry';
13
15
  export { petsCategory } from './category';
14
16
  import { petAge } from './tool/petAge/entry';
15
17
  import { petRation } from './tool/petRation/entry';
@@ -18,4 +20,5 @@ import { petToxicity } from './tool/petToxicity/entry';
18
20
  import { petWaterIntake } from './tool/petWaterIntake/entry';
19
21
  import { petCarrierCrateSizePlanner } from './tool/petCarrierCrateSizePlanner/entry';
20
22
  import { petMedicationSchedulePlanner } from './tool/petMedicationSchedulePlanner/entry';
21
- export const ALL_ENTRIES = [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner, petMedicationSchedulePlanner];
23
+ import { petCollarSizeCalculator } from './tool/petCollarSizeCalculator/entry';
24
+ export const ALL_ENTRIES = [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner, petMedicationSchedulePlanner, petCollarSizeCalculator];
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export { petRation, PET_RATION_TOOL } from './tool/petRation';
3
3
  export { petGestation, PET_GESTATION_TOOL } from './tool/petGestation';
4
4
  export { petCarrierCrateSizePlanner, PET_CARRIER_CRATE_SIZE_PLANNER_TOOL } from './tool/petCarrierCrateSizePlanner';
5
5
  export { petMedicationSchedulePlanner, PET_MEDICATION_SCHEDULE_PLANNER_TOOL } from './tool/petMedicationSchedulePlanner';
6
+ export { petCollarSizeCalculator, PET_COLLAR_SIZE_CALCULATOR_TOOL } from './tool/petCollarSizeCalculator';
6
7
 
7
8
  export { petsCategory } from './category';
8
9
  export const PetsCategorySEO = () => import('./category/seo.astro').then((m) => m.default);
@@ -4,7 +4,7 @@ import { ALL_TOOLS } from '../tools';
4
4
  const EXPECTED_LOCALES = [
5
5
  'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'
6
6
  ];
7
- const ENGLISH_FIRST_TOOLS = new Set(['pet-carrier-crate-size-planner']);
7
+ const ENGLISH_FIRST_TOOLS = new Set(['pet-carrier-crate-size-planner', 'pet-collar-size-calculator']);
8
8
 
9
9
  describe('I18n Coverage Validation', () => {
10
10
  it('all tools should be registered', () => {
@@ -26,6 +26,6 @@ describe('Locale Completeness Validation', () => {
26
26
  });
27
27
 
28
28
  it('all tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(7);
29
+ expect(ALL_TOOLS.length).toBe(8);
30
30
  });
31
31
  });
@@ -0,0 +1,54 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import { CATEGORY_OG_IMAGE, getUtilityOgImage } from '../mfe/assets';
6
+
7
+ const categoryImageMatch = CATEGORY_OG_IMAGE.match(
8
+ /^(\/_utilities\/[^/]+\/images)\/([^/]+\.webp)\?version=(.+)$/,
9
+ );
10
+
11
+ if (!categoryImageMatch) {
12
+ throw new Error(`Unexpected CATEGORY_OG_IMAGE format: ${CATEGORY_OG_IMAGE}`);
13
+ }
14
+
15
+ const [, matchedImageUrlRoot, matchedCategoryImage, matchedAssetVersion] = categoryImageMatch;
16
+ if (!matchedImageUrlRoot || !matchedCategoryImage || !matchedAssetVersion) {
17
+ throw new Error('Unexpected CATEGORY_OG_IMAGE capture groups');
18
+ }
19
+ const imageUrlRoot = matchedImageUrlRoot;
20
+ const categoryImage = matchedCategoryImage;
21
+ const assetVersion = matchedAssetVersion;
22
+ const assetRoot = join(process.cwd(), 'public', imageUrlRoot.slice(1));
23
+ const categorySlug = basename(categoryImage, '.webp');
24
+
25
+ describe('MFE asset contract', () => {
26
+ it('has one non-empty English-slug OG image per category and registered tool', async () => {
27
+ const expectedSlugs = new Set([categorySlug]);
28
+
29
+ for (const { entry } of ALL_TOOLS) {
30
+ const englishLoader = entry.i18n.en;
31
+ if (!englishLoader) throw new Error(`Missing English locale for ${entry.id}`);
32
+
33
+ const englishContent = await englishLoader();
34
+ expectedSlugs.add(englishContent.slug);
35
+ }
36
+
37
+ const actualSlugs = new Set(
38
+ readdirSync(assetRoot)
39
+ .filter((filename) => filename.endsWith('.webp'))
40
+ .map((filename) => filename.slice(0, -'.webp'.length)),
41
+ );
42
+
43
+ expect(actualSlugs).toEqual(expectedSlugs);
44
+
45
+ for (const slug of expectedSlugs) {
46
+ const imagePath = join(assetRoot, `${slug}.webp`);
47
+ expect(existsSync(imagePath), `${imagePath} should exist`).toBe(true);
48
+ expect(statSync(imagePath).size, `${imagePath} should not be empty`).toBeGreaterThan(0);
49
+ expect(getUtilityOgImage(slug)).toBe(
50
+ `${imageUrlRoot}/${slug}.webp?version=${assetVersion}`,
51
+ );
52
+ }
53
+ }, 30000);
54
+ });
@@ -73,7 +73,7 @@ describe('Tool Validation Suite', () => {
73
73
  expect(content.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
74
74
 
75
75
  if (locale === 'es') {
76
- 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', 'planificador-horario-medicacion-mascota'];
76
+ 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', 'planificador-horario-medicacion-mascota', 'calculadora-talla-collar-mascotas'];
77
77
  expect(validSlugs).toContain(content.slug);
78
78
  }
79
79
  });
@@ -99,11 +99,11 @@ describe('Tool Validation Suite', () => {
99
99
 
100
100
  describe('Library Registration', () => {
101
101
  it('should have 7 tools in ALL_TOOLS', () => {
102
- expect(ALL_TOOLS.length).toBe(7);
102
+ expect(ALL_TOOLS.length).toBe(8);
103
103
  });
104
104
 
105
105
  it('should have all tools in petsCategory', () => {
106
- expect(petsCategory.tools.length).toBe(7);
106
+ expect(petsCategory.tools.length).toBe(8);
107
107
  ALL_TOOLS.forEach(({ entry }) => {
108
108
  const exists = petsCategory.tools.some((t: any) => t.id === entry.id);
109
109
  expect(exists).toBe(true);
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliography} />
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'PDSA: How can I be a responsible dog owner?',
6
+ url: 'https://www.pdsa.org.uk/what-we-do/blog/vet-qa-how-can-i-be-a-responsible-dog-owner',
7
+ },
8
+ {
9
+ name: 'Deutscher Tierschutzbund: Hunde richtig halten',
10
+ url: 'https://www.tierschutzbund.de/tiere-themen/haustiere/hunde/hundehaltung/',
11
+ },
12
+ ];
@@ -0,0 +1,65 @@
1
+ ---
2
+ import './pet-collar-size-calculator.css';
3
+ import { calculatePetCollarSize } from './logic';
4
+ import { renderResult, renderScene } from './dom-views';
5
+ import { petCollarSizeCalculator } from './entry';
6
+
7
+ const content = await petCollarSizeCalculator.i18n.en?.();
8
+ if (!content) return null;
9
+ const { ui } = content;
10
+ const initialResult = calculatePetCollarSize({
11
+ neckCircumference: 32,
12
+ allowance: 2,
13
+ unit: 'cm',
14
+ collarType: 'flat',
15
+ });
16
+ const config = { ui };
17
+ ---
18
+
19
+ <section class="pet-collar-size-calculator" data-pet-collar-root aria-label={content.title}>
20
+ <div class="pet-collar-atelier">
21
+ <aside class="pet-collar-atelier-rail" aria-hidden="true">
22
+ <span class="pet-collar-rail-index">01</span>
23
+ <span class="pet-collar-rail-word">fit study</span>
24
+ <span class="pet-collar-rail-mark"></span>
25
+ </aside>
26
+ <div class="pet-collar-atelier-body">
27
+ <header class="pet-collar-atelier-header">
28
+ <span class="pet-collar-kicker">{ui.fitStudyLabel}</span>
29
+ <fieldset class="pet-collar-type-field">
30
+ <legend>{ui.collarTypeLabel}</legend>
31
+ <div class="pet-collar-type-options">
32
+ <label><input type="radio" name="pet-collar-type" data-collar-type="flat" value="flat" checked /><span>{ui.flatCollar}</span></label>
33
+ <label><input type="radio" name="pet-collar-type" data-collar-type="breakaway" value="breakaway" /><span>{ui.breakawayCollar}</span></label>
34
+ <label><input type="radio" name="pet-collar-type" data-collar-type="martingale" value="martingale" /><span>{ui.martingaleCollar}</span></label>
35
+ </div>
36
+ </fieldset>
37
+ </header>
38
+ <div class="pet-collar-scene" data-collar-scene set:html={renderScene(initialResult, ui)}></div>
39
+ <form class="pet-collar-controls" data-collar-form onsubmit="return false;">
40
+ <div class="pet-collar-control pet-collar-control-neck">
41
+ <label for="pet-collar-neck-input">{ui.neckCircumferenceLabel}</label>
42
+ <div class="pet-collar-number-wrap"><input id="pet-collar-neck-input" data-neck-input type="number" value="32" min="5" max="250" step="0.5" inputmode="decimal" /><span data-unit-suffix>cm</span></div>
43
+ </div>
44
+ <div class="pet-collar-control pet-collar-control-room">
45
+ <label for="pet-collar-allowance-range">{ui.allowanceLabel}</label>
46
+ <div class="pet-collar-range-row"><input id="pet-collar-allowance-range" data-allowance-range type="range" value="2" min="0.5" max="10" step="0.5" aria-label={ui.allowanceLabel} /><output data-allowance-output for="pet-collar-allowance-range">2 <span data-unit-suffix>cm</span></output></div>
47
+ </div>
48
+ <div class="pet-collar-control pet-collar-control-unit">
49
+ <span class="pet-collar-label">{ui.unitLabel}</span>
50
+ <div class="pet-collar-unit-switch" role="group" aria-label={ui.unitLabel}><button type="button" data-unit-choice="cm" aria-label={ui.centimetres} aria-pressed="true" class="is-active">cm</button><button type="button" data-unit-choice="in" aria-label={ui.inches} aria-pressed="false">in</button></div>
51
+ </div>
52
+ </form>
53
+ <div class="pet-collar-result" data-collar-result aria-live="polite" set:html={renderResult(initialResult, ui)}></div>
54
+ </div>
55
+ </div>
56
+ </section>
57
+
58
+ <script is:inline type="application/json" data-pet-collar-config set:html={JSON.stringify(config)}></script>
59
+ <script>
60
+ import { mountPetCollarSizeCalculator } from './controller';
61
+
62
+ const root = document.querySelector<HTMLElement>('[data-pet-collar-root]');
63
+ const configElement = document.querySelector<HTMLScriptElement>('[data-pet-collar-config]');
64
+ if (root && configElement?.textContent) mountPetCollarSizeCalculator(root, JSON.parse(configElement.textContent));
65
+ </script>
@@ -0,0 +1,160 @@
1
+ import { calculatePetCollarSize, fromCentimetres, toCentimetres, type CollarType, type CollarUnit } from './logic';
2
+ import { renderResult, renderScene } from './dom-views';
3
+ import { loadCollarState, saveCollarState } from './storage';
4
+ import type { PetCollarSizeCalculatorUI } from './ui';
5
+
6
+ interface ControllerConfig {
7
+ ui: PetCollarSizeCalculatorUI;
8
+ }
9
+
10
+ function toNumber(value: string | undefined, fallback: number): number {
11
+ const parsed = Number(value);
12
+ return Number.isFinite(parsed) ? parsed : fallback;
13
+ }
14
+
15
+ function setUnitConstraints(root: HTMLElement, unit: CollarUnit): void {
16
+ const neck = root.querySelector<HTMLInputElement>('[data-neck-input]');
17
+ const allowanceRange = root.querySelector<HTMLInputElement>('[data-allowance-range]');
18
+ if (!neck || !allowanceRange) return;
19
+
20
+ const constraints = unit === 'in'
21
+ ? { neckMin: '2', neckMax: '98.4', neckStep: '0.1', allowanceMin: '0.2', allowanceMax: '3.9', allowanceStep: '0.1' }
22
+ : { neckMin: '5', neckMax: '250', neckStep: '0.5', allowanceMin: '0.5', allowanceMax: '10', allowanceStep: '0.5' };
23
+ neck.min = constraints.neckMin;
24
+ neck.max = constraints.neckMax;
25
+ neck.step = constraints.neckStep;
26
+ allowanceRange.min = constraints.allowanceMin;
27
+ allowanceRange.max = constraints.allowanceMax;
28
+ allowanceRange.step = constraints.allowanceStep;
29
+ }
30
+
31
+ function updateAllowanceOutput(root: HTMLElement, value: string, unit: CollarUnit): void {
32
+ const output = root.querySelector<HTMLOutputElement>('[data-allowance-output]');
33
+ if (!output) return;
34
+ output.firstChild?.replaceWith(document.createTextNode(value));
35
+ output.querySelector('[data-unit-suffix]')?.replaceChildren(document.createTextNode(unit === 'in' ? 'in' : 'cm'));
36
+ }
37
+
38
+ function selectUnit(root: HTMLElement, unit: CollarUnit): void {
39
+ root.querySelectorAll<HTMLElement>('[data-unit-choice]').forEach((button) => {
40
+ const active = button.dataset.unitChoice === unit;
41
+ button.classList.toggle('is-active', active);
42
+ button.setAttribute('aria-pressed', String(active));
43
+ });
44
+ root.querySelectorAll<HTMLElement>('[data-unit-suffix]').forEach((suffix) => {
45
+ suffix.textContent = unit === 'in' ? 'in' : 'cm';
46
+ });
47
+ }
48
+
49
+ export function mountPetCollarSizeCalculator(root: HTMLElement, config: ControllerConfig): void {
50
+ const elements = getMountElements(root);
51
+ if (!elements) return;
52
+ const state = prepareInitialState(root, elements);
53
+ const update = createUpdater({ ...elements, config, state });
54
+ bindTypeInputs(root, state, update);
55
+ bindValueInputs({ root, neck: elements.neck, allowanceRange: elements.allowanceRange, unit: () => state.unit, update });
56
+ bindUnitButtons({ root, ...elements, state, update });
57
+ update();
58
+ }
59
+
60
+ interface MountElements {
61
+ neck: HTMLInputElement;
62
+ allowanceRange: HTMLInputElement;
63
+ scene: HTMLElement;
64
+ resultView: HTMLElement;
65
+ }
66
+
67
+ function getMountElements(root: HTMLElement): MountElements | null {
68
+ const neck = root.querySelector<HTMLInputElement>('[data-neck-input]');
69
+ const allowanceRange = root.querySelector<HTMLInputElement>('[data-allowance-range]');
70
+ const scene = root.querySelector<HTMLElement>('[data-collar-scene]');
71
+ const resultView = root.querySelector<HTMLElement>('[data-collar-result]');
72
+ if (!neck || !allowanceRange || !scene || !resultView) return null;
73
+ return { neck, allowanceRange, scene, resultView };
74
+ }
75
+
76
+ function prepareInitialState(root: HTMLElement, elements: MountElements): { unit: CollarUnit; collarType: CollarType } {
77
+ const stored = loadCollarState();
78
+ const state = { unit: stored.unit ?? 'cm', collarType: stored.collarType ?? 'flat' };
79
+ setUnitConstraints(root, state.unit);
80
+ elements.neck.value = String(stored.neckCircumference ?? 32);
81
+ elements.allowanceRange.value = String(stored.allowance ?? 2);
82
+ root.querySelector<HTMLInputElement>(`[data-collar-type="${state.collarType}"]`)?.setAttribute('checked', '');
83
+ selectUnit(root, state.unit);
84
+ updateAllowanceOutput(root, elements.allowanceRange.value, state.unit);
85
+ return state;
86
+ }
87
+
88
+ interface UpdaterArgs {
89
+ neck: HTMLInputElement;
90
+ allowanceRange: HTMLInputElement;
91
+ scene: HTMLElement;
92
+ resultView: HTMLElement;
93
+ config: ControllerConfig;
94
+ state: { unit: CollarUnit; collarType: CollarType };
95
+ }
96
+
97
+ function createUpdater(args: UpdaterArgs): () => void {
98
+ return () => {
99
+ const result = calculatePetCollarSize({
100
+ neckCircumference: toNumber(args.neck.value, 0),
101
+ allowance: toNumber(args.allowanceRange.value, 0),
102
+ unit: args.state.unit,
103
+ collarType: args.state.collarType,
104
+ });
105
+ args.scene.innerHTML = renderScene(result, args.config.ui);
106
+ args.resultView.innerHTML = renderResult(result, args.config.ui);
107
+ saveCollarState({
108
+ neckCircumference: toNumber(args.neck.value, 0),
109
+ allowance: toNumber(args.allowanceRange.value, 0),
110
+ unit: args.state.unit,
111
+ collarType: args.state.collarType,
112
+ });
113
+ };
114
+ }
115
+
116
+ function bindTypeInputs(root: HTMLElement, state: { collarType: CollarType }, update: () => void): void {
117
+ root.querySelectorAll<HTMLInputElement>('[data-collar-type]').forEach((input) => input.addEventListener('change', () => {
118
+ if (input.checked) state.collarType = input.value as CollarType;
119
+ update();
120
+ }));
121
+ }
122
+
123
+ interface ValueBindingArgs {
124
+ root: HTMLElement;
125
+ neck: HTMLInputElement;
126
+ allowanceRange: HTMLInputElement;
127
+ unit: () => CollarUnit;
128
+ update: () => void;
129
+ }
130
+
131
+ function bindValueInputs(args: ValueBindingArgs): void {
132
+ args.neck.addEventListener('input', args.update);
133
+ args.allowanceRange.addEventListener('input', () => {
134
+ updateAllowanceOutput(args.root, args.allowanceRange.value, args.unit());
135
+ args.update();
136
+ });
137
+ }
138
+
139
+ interface UnitBindingArgs {
140
+ root: HTMLElement;
141
+ neck: HTMLInputElement;
142
+ allowanceRange: HTMLInputElement;
143
+ state: { unit: CollarUnit };
144
+ update: () => void;
145
+ }
146
+
147
+ function bindUnitButtons(args: UnitBindingArgs): void {
148
+ args.root.querySelectorAll<HTMLElement>('[data-unit-choice]').forEach((button) => button.addEventListener('click', () => {
149
+ const nextUnit = button.dataset.unitChoice as CollarUnit;
150
+ const neckCm = toCentimetres(toNumber(args.neck.value, 0), args.state.unit);
151
+ const allowanceCm = toCentimetres(toNumber(args.allowanceRange.value, 0), args.state.unit);
152
+ args.state.unit = nextUnit;
153
+ setUnitConstraints(args.root, nextUnit);
154
+ args.neck.value = String(Number(fromCentimetres(neckCm, nextUnit).toFixed(1)));
155
+ args.allowanceRange.value = String(Number(fromCentimetres(allowanceCm, nextUnit).toFixed(1)));
156
+ selectUnit(args.root, nextUnit);
157
+ updateAllowanceOutput(args.root, args.allowanceRange.value, nextUnit);
158
+ args.update();
159
+ }));
160
+ }
@@ -0,0 +1,111 @@
1
+ import type { PetCollarSizeCalculatorUI } from './ui';
2
+ import type { CollarType, CollarUnit, PetCollarSizeResult } from './logic';
3
+ import { getFitReview } from './evaluator';
4
+
5
+ function escapeHtml(value: string): string {
6
+ return value.replace(/[&<>"']/g, (character) => ({
7
+ '&': '&amp;',
8
+ '<': '&lt;',
9
+ '>': '&gt;',
10
+ '"': '&quot;',
11
+ "'": '&#39;',
12
+ })[character] ?? character);
13
+ }
14
+
15
+ export function formatMeasurement(valueCm: number, unit: CollarUnit): string {
16
+ const value = unit === 'in' ? valueCm / 2.54 : valueCm;
17
+ return value.toFixed(1).replace(/\.0$/, '');
18
+ }
19
+
20
+ function unitLabel(unit: CollarUnit, ui: PetCollarSizeCalculatorUI): string {
21
+ return unit === 'in' ? ui.inches : ui.centimetres;
22
+ }
23
+
24
+ function collarTypeLabel(type: CollarType, ui: PetCollarSizeCalculatorUI): string {
25
+ return { flat: ui.flatCollar, breakaway: ui.breakawayCollar, martingale: ui.martingaleCollar }[type];
26
+ }
27
+
28
+ function collarCheckText(type: CollarType, ui: PetCollarSizeCalculatorUI): string {
29
+ return { flat: ui.checkFlat, breakaway: ui.checkBreakaway, martingale: ui.checkMartingale }[type];
30
+ }
31
+
32
+ function point(cx: number, cy: number, radius: number, degrees: number): { x: number; y: number } {
33
+ const radians = (degrees * Math.PI) / 180;
34
+ return { x: cx + Math.cos(radians) * radius, y: cy + Math.sin(radians) * radius };
35
+ }
36
+
37
+ interface ArcPathArgs {
38
+ cx: number;
39
+ cy: number;
40
+ radius: number;
41
+ start: number;
42
+ end: number;
43
+ }
44
+
45
+ function arcPath(args: ArcPathArgs): string {
46
+ const first = point(args.cx, args.cy, args.radius, args.start);
47
+ const last = point(args.cx, args.cy, args.radius, args.end);
48
+ const largeArc = Math.abs(args.end - args.start) > 180 ? 1 : 0;
49
+ return `M${first.x.toFixed(1)} ${first.y.toFixed(1)}A${args.radius} ${args.radius} 0 ${largeArc} 1 ${last.x.toFixed(1)} ${last.y.toFixed(1)}`;
50
+ }
51
+
52
+ function renderWheelTicks(): string {
53
+ const ticks = Array.from({ length: 12 }, (_, index) => {
54
+ const angle = index * 30;
55
+ const inner = point(196, 204, 153, angle);
56
+ const outer = point(196, 204, index % 3 === 0 ? 166 : 160, angle);
57
+ return `<line class="pet-collar-wheel-tick" x1="${inner.x.toFixed(1)}" y1="${inner.y.toFixed(1)}" x2="${outer.x.toFixed(1)}" y2="${outer.y.toFixed(1)}" />`;
58
+ });
59
+ return ticks.join('');
60
+ }
61
+
62
+ function renderBuckle(): string {
63
+ return '<path class="pet-collar-wheel-buckle" d="M325 82h27v45h-27m0-35h19m-19 25h19" />';
64
+ }
65
+
66
+ function renderWheel(result: PetCollarSizeResult, ui: PetCollarSizeCalculatorUI): string {
67
+ const roomOffset = Math.min(30, Math.max(2, result.allowanceCm * 3));
68
+ const collarStart = 104 + roomOffset;
69
+ const collarEnd = collarStart + 24;
70
+ const neckPath = arcPath({ cx: 196, cy: 204, radius: 80, start: 205, end: 505 });
71
+ const spanPath = arcPath({ cx: 196, cy: 204, radius: collarStart, start: 205, end: 505 });
72
+ const endPath = arcPath({ cx: 196, cy: 204, radius: collarEnd, start: 205, end: 505 });
73
+ const roomPoint = point(196, 204, 92, 252);
74
+ const spanPoint = point(196, 204, collarEnd + 10, 318);
75
+ return `<svg class="pet-collar-fit-wheel" viewBox="0 0 720 420" role="img" aria-label="${escapeHtml(ui.sceneAria)}">
76
+ <circle class="pet-collar-wheel-paper" cx="196" cy="204" r="173" />
77
+ ${renderWheelTicks()}
78
+ <path class="pet-collar-wheel-neck" d="${neckPath}" />
79
+ <path class="pet-collar-wheel-band" d="${spanPath}" />
80
+ <path class="pet-collar-wheel-edge" d="${endPath}" />
81
+ ${renderBuckle()}
82
+ <circle class="pet-collar-wheel-center" cx="196" cy="204" r="5" />
83
+ <path class="pet-collar-wheel-leader pet-collar-wheel-leader-room" d="M${roomPoint.x.toFixed(1)} ${roomPoint.y.toFixed(1)}L92 342H36" />
84
+ <path class="pet-collar-wheel-leader pet-collar-wheel-leader-span" d="M${spanPoint.x.toFixed(1)} ${spanPoint.y.toFixed(1)}L446 286H516" />
85
+ <text class="pet-collar-wheel-label pet-collar-wheel-label-neck" x="196" y="198" text-anchor="middle">${escapeHtml(ui.neckMarker)}</text>
86
+ <text class="pet-collar-wheel-sublabel" x="196" y="220" text-anchor="middle">fit circle</text>
87
+ <text class="pet-collar-wheel-label" x="36" y="362">${escapeHtml(ui.easeMarker)}</text>
88
+ <text class="pet-collar-wheel-sublabel" x="36" y="381">+${escapeHtml(formatMeasurement(result.allowanceCm, result.unit))} ${escapeHtml(unitLabel(result.unit, ui))}</text>
89
+ <text class="pet-collar-wheel-label pet-collar-wheel-label-span" x="516" y="306">${escapeHtml(ui.rangeMarker)}</text>
90
+ <text class="pet-collar-wheel-sublabel" x="516" y="325">${escapeHtml(ui.minimumMarker)} to ${escapeHtml(ui.maximumMarker)}</text>
91
+ </svg>`;
92
+ }
93
+
94
+ function renderReading(result: PetCollarSizeResult, ui: PetCollarSizeCalculatorUI): string {
95
+ const unit = unitLabel(result.unit, ui);
96
+ return `<div class="pet-collar-reading"><span class="pet-collar-reading-label">${escapeHtml(ui.fitWindowLabel)}</span><strong>${escapeHtml(formatMeasurement(result.minimumCm, result.unit))} <small>to</small> ${escapeHtml(formatMeasurement(result.maximumCm, result.unit))}<em>${escapeHtml(unit)}</em></strong><div class="pet-collar-reading-metrics"><div><span>${escapeHtml(ui.measuredLabel)}</span><b>${escapeHtml(formatMeasurement(result.neckCm, result.unit))} ${escapeHtml(unit)}</b></div><div><span>${escapeHtml(ui.roomLabel)}</span><b>+${escapeHtml(formatMeasurement(result.allowanceCm, result.unit))} ${escapeHtml(unit)}</b></div></div></div>`;
97
+ }
98
+
99
+ export function renderScene(result: PetCollarSizeResult, ui: PetCollarSizeCalculatorUI): string {
100
+ if (!result.valid) return `<div class="pet-collar-invalid" role="status">${escapeHtml(ui.invalidInput)}</div>`;
101
+ return `<div class="pet-collar-stage"><div class="pet-collar-wheel-wrap">${renderWheel(result, ui)}<span class="pet-collar-stage-index">fit / 01</span></div>${renderReading(result, ui)}</div>`;
102
+ }
103
+
104
+ export function renderResult(result: PetCollarSizeResult, ui: PetCollarSizeCalculatorUI): string {
105
+ if (!result.valid) return `<div class="pet-collar-result-invalid" role="status">${escapeHtml(ui.invalidInput)}</div>`;
106
+ const review = getFitReview(result);
107
+ const statusLabel = ui[review.labelKey];
108
+ const statusDetail = ui[review.detailKey];
109
+ const collarType = collarTypeLabel(result.collarType, ui);
110
+ return `<div class="pet-collar-guidance"><div class="pet-collar-guidance-main"><span class="pet-collar-status pet-collar-status-${review.status}">${escapeHtml(statusLabel)}</span><span>${escapeHtml(statusDetail)}</span></div><p><span>${escapeHtml(ui.checkLabel)}</span><strong>${escapeHtml(collarType)}</strong>${escapeHtml(collarCheckText(result.collarType, ui))}</p></div>`;
111
+ }
@@ -0,0 +1,32 @@
1
+ import type { PetToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { PetCollarSizeCalculatorUI } from './ui';
3
+
4
+ export type { PetCollarSizeCalculatorUI } from './ui';
5
+ export type PetCollarSizeCalculatorLocaleContent = ToolLocaleContent<PetCollarSizeCalculatorUI>;
6
+
7
+ export const petCollarSizeCalculator: PetToolEntry<PetCollarSizeCalculatorUI> = {
8
+ id: 'pet-collar-size-calculator',
9
+ icons: {
10
+ bg: 'mdi:paw',
11
+ fg: 'mdi:link-variant',
12
+ },
13
+ i18n: {
14
+ de: () => import('./i18n/de').then((module) => module.content),
15
+ en: () => import('./i18n/en').then((module) => module.content),
16
+ es: () => import('./i18n/es').then((module) => module.content),
17
+ fr: () => import('./i18n/fr').then((module) => module.content),
18
+ id: () => import('./i18n/id').then((module) => module.content),
19
+ it: () => import('./i18n/it').then((module) => module.content),
20
+ ja: () => import('./i18n/ja').then((module) => module.content),
21
+ ko: () => import('./i18n/ko').then((module) => module.content),
22
+ nl: () => import('./i18n/nl').then((module) => module.content),
23
+ pl: () => import('./i18n/pl').then((module) => module.content),
24
+ pt: () => import('./i18n/pt').then((module) => module.content),
25
+ ru: () => import('./i18n/ru').then((module) => module.content),
26
+ sv: () => import('./i18n/sv').then((module) => module.content),
27
+ tr: () => import('./i18n/tr').then((module) => module.content),
28
+ zh: () => import('./i18n/zh').then((module) => module.content),
29
+ },
30
+ };
31
+
32
+ export { bibliography } from './bibliography';
@@ -0,0 +1,18 @@
1
+ import type { AllowanceStatus, CollarType, PetCollarSizeResult } from './logic';
2
+
3
+ export interface FitReview {
4
+ status: AllowanceStatus;
5
+ collarType: CollarType;
6
+ labelKey: 'statusTight' | 'statusBalanced' | 'statusLoose';
7
+ detailKey: 'statusTightDetail' | 'statusBalancedDetail' | 'statusLooseDetail';
8
+ }
9
+
10
+ export function getFitReview(result: PetCollarSizeResult): FitReview {
11
+ const keys = {
12
+ tight: { labelKey: 'statusTight', detailKey: 'statusTightDetail' },
13
+ balanced: { labelKey: 'statusBalanced', detailKey: 'statusBalancedDetail' },
14
+ loose: { labelKey: 'statusLoose', detailKey: 'statusLooseDetail' },
15
+ } as const;
16
+
17
+ return { status: result.status, collarType: result.collarType, ...keys[result.status] };
18
+ }
@@ -0,0 +1,44 @@
1
+ import { bibliography } from '../bibliography';
2
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
3
+ import type { PetCollarSizeCalculatorLocaleContent } from '../entry';
4
+ import type { PetCollarSizeCalculatorUI } from '../ui';
5
+
6
+ const slug = 'halsband-groesse-haustier-berechnen';
7
+ const title = 'Halsbandgröße für Haustiere berechnen';
8
+ const description = 'Messen Sie den Halsumfang, addieren Sie Bewegungsfreiheit und finden Sie einen verstellbaren Halsbandbereich für die Handprüfung.';
9
+ const ui: PetCollarSizeCalculatorUI = {
10
+ neckCircumferenceLabel: 'Halsumfang', unitLabel: 'Anzeige', centimetres: 'Zentimeter', inches: 'Zoll', allowanceLabel: 'Bewegungsfreiheit', collarTypeLabel: 'Halsbandtyp', flatCollar: 'Flach', breakawayCollar: 'Sicherheitsverschluss', martingaleCollar: 'Martingale', statusTight: 'Mehr Raum einplanen', statusBalanced: 'Bereit zur Prüfung', statusLoose: 'Sicherheit prüfen', statusTightDetail: 'Mehr Raum einplanen.', statusBalancedDetail: 'Diesen Bereich von Hand prüfen.', statusLooseDetail: 'Prüfen, dass es nicht abrutschen kann.', fitStudyLabel: 'Halsband-Passformstudie', fitWindowLabel: 'Diesen Bereich suchen', measuredLabel: 'Gemessener Hals', roomLabel: 'Bewegungsfreiheit', checkLabel: 'Prüfung von Hand', sceneAria: 'Passformstudie mit Halsring und verstellbarem Halsbandbereich', neckMarker: 'Hals', easeMarker: 'Raum', rangeMarker: 'Halsbandbereich', minimumMarker: 'Start', maximumMarker: 'Ende', checkFlat: 'Bequem, sicher, ohne Reibung.', checkBreakaway: 'Bequeme Passform und korrekte Auslösevorrichtung prüfen.', checkMartingale: 'Die Zugschlaufe darf den Hals nicht einengen.', invalidInput: 'Geben Sie einen gültigen Halsumfang und Bewegungsraum ein.',
11
+ };
12
+ const faq: PetCollarSizeCalculatorLocaleContent['faq'] = [
13
+ { question: 'Wie messe ich den Halsumfang meines Tieres?', answer: 'Legen Sie ein flexibles Maßband dort um den Hals, wo das Halsband sitzen soll. Halten Sie es gerade und nah am Fell, ohne Haut oder Fell zusammenzudrücken. Messen Sie erneut, wenn sich die Position, das Fell oder die Haltung des Tieres ändert.' },
14
+ { question: 'Warum zeigt der Rechner einen Bereich statt einer Groesse?', answer: 'Verstellbare Halsbaender haben unterschiedliche Einstellbereiche und Hersteller verwenden keine einheitlichen Groessen. Der Rechner addiert Ihren gewaehlten Bewegungsraum und gibt einen kleinen Einkaufsbereich aus. Danach muss das konkrete Halsband am Tier geprueft werden.' },
15
+ { question: 'Wie viel Bewegungsfreiheit soll ich einplanen?', answer: 'Es gibt keinen universellen Zentimeterwert. Fell, Halsbandbreite, Bauform und Nutzung verändern die passende Einstellung. Verwenden Sie den Wert als sichtbare Annahme und bestätigen Sie die Passform anschließend mit einer ruhigen Handprüfung.' },
16
+ { question: 'Gilt die Rechnung auch fuer Martingale-Halsbaender?', answer: 'Die erste Groessenschaetzung beginnt mit demselben Halsumfang. Bei einem Martingale-Halsband ist die Sicherheitspruefung jedoch anders: Die Zugschlaufe muss begrenzt sein und darf den Hals nicht einengen. Beachten Sie immer die Herstellerangaben.' },
17
+ { question: 'Kann ich den Bereich bei einem jungen Tier verwenden?', answer: 'Ja, als Momentaufnahme. Junge Tiere wachsen und verändern ihr Fell, deshalb sollte die Messung regelmäßig wiederholt werden. Ein heutiger Bereich ersetzt keine spätere Kontrolle.' },
18
+ ];
19
+ const howTo: PetCollarSizeCalculatorLocaleContent['howTo'] = [
20
+ { name: 'Hals messen', text: 'Messen Sie den ruhigen, natuerlich stehenden Hals dort, wo das Halsband liegen wird. Das Massband soll flach und nicht straff sein.' },
21
+ { name: 'Bewegungsraum waehlen', text: 'Stellen Sie den Raum zwischen Hals und Halsband als sichtbaren Eingabewert ein. Zwei Zentimeter sind nur ein leicht lesbares Beispiel.' },
22
+ { name: 'Halsbandtyp auswaehlen', text: 'Waehlen Sie Flach, Sicherheitsverschluss oder Martingale, damit die abschliessende Pruefung zum Verschluss passt.' },
23
+ { name: 'Einstellbereich vergleichen', text: 'Suchen Sie ein verstellbares Halsband, das den gesamten Bereich abdeckt, und pruefen Sie es anschliessend am Tier.' },
24
+ ];
25
+ const seo: PetCollarSizeCalculatorLocaleContent['seo'] = [
26
+ { type: 'summary', title: 'Vom Halsmass zum passenden Einkaufsbereich', items: ['Messen Sie dort, wo das Halsband wirklich sitzen wird.', 'Machen Sie den Bewegungsraum sichtbar statt nach Rasse oder Gewicht zu raten.', 'Vergleichen Sie den Bereich mit der echten Verstellspanne des Produkts.', 'Beenden Sie die Auswahl mit einer ruhigen Handpruefung.'] },
27
+ { type: 'title', text: 'Was diese Halsbandberechnung entscheidet', level: 2 },
28
+ { type: 'paragraph', html: 'Eine Groessentabelle ist oft unklar, wenn ein Tier zwischen zwei Groessen liegt, dichtes Fell hat oder noch waechst. Dieser Rechner beginnt mit einem Messwert, den Sie selbst kontrollieren: dem Umfang an der Stelle, an der das Halsband liegen soll. Dazu kommt der Bewegungsraum, den Sie bewusst waehlen. Das Ergebnis ist kein Marketinglabel wie klein, mittel oder gross, sondern ein konkreter Bereich, den Sie mit Produktangaben vergleichen koennen.' },
29
+ { type: 'paragraph', html: 'Der Bereich bleibt absichtlich klein. Verstellbare Halsbaender werden nicht nach einer weltweit einheitlichen Norm gefertigt, und die nutzbare Laenge kann sich von der Bezeichnung auf einer Produktseite unterscheiden. Ein Produkt passt erst dann in die Auswahl, wenn seine Verstellung den ganzen Bereich abdeckt. Danach pruefen Sie Kanten, Verschluss, Fell, Bewegung und die Angaben des Herstellers.' },
30
+ { type: 'title', text: 'So messen Sie vor dem Einkauf', level: 2 },
31
+ { type: 'list', items: ['Messen Sie, wenn das Tier ruhig steht und normal atmet.', 'Halten Sie das Band gerade und druecken Sie Fell oder Haut nicht zusammen.', 'Wiederholen Sie die Messung, wenn das Halsband hoeher oder tiefer sitzen soll.', 'Bei jungen Tieren das Datum notieren und die Passform regelmaessig neu pruefen.'] },
32
+ { type: 'paragraph', html: 'Der Bewegungsraum ist eine Annahme, keine Sicherheitsgarantie. Fell, Halsbandbreite, Material, Verschluss und Aktivitaet beeinflussen die richtige Einstellung. Eine allgemeine Fingerpruefung kann helfen, ersetzt aber keine Beobachtung des individuellen Tieres. Wenn Reiben, Husten, Schmerzen, Atemprobleme oder Stress auftreten, nehmen Sie das Halsband ab und holen Sie fachkundige Hilfe.' },
33
+ { type: 'title', text: 'Die Signale im Passformrad lesen', level: 2 },
34
+ { type: 'table', headers: ['Signal', 'Bedeutung', 'Naechster Schritt'], rows: [['Gemessener Hals', 'Der Umfang vor dem Bewegungsraum.', 'Messung bei Bedarf wiederholen.'], ['Start', 'Halsumfang plus Ihr Bewegungsraum.', 'Als untere Grenze der Verstellung verwenden.'], ['Ende', 'Ein kleiner Vergleichsbereich oberhalb des Starts.', 'Produkt suchen, das ihn vollstaendig abdeckt.'], ['Pruefung', 'Hinweis auf die gewaehlte Annahme.', 'Am Tier von Hand bestaetigen.']] },
35
+ { type: 'title', text: 'Der Halsbandtyp ändert die letzte Prüfung', level: 2 },
36
+ { type: 'paragraph', html: 'Ein flaches Halsband braucht eine andere Handprüfung als ein Sicherheitsverschluss oder ein Martingale-Modell. Achten Sie auf die Kante, die Schnalle und die Begrenzung der Zugschlaufe. Die Zahl hilft bei der Auswahl; das ruhige Tier und die Herstellerangaben entscheiden über die tatsächliche Verwendung.' },
37
+ { type: 'tip', title: 'Leise Handpruefung', html: 'Legen Sie das Halsband an, wenn das Tier ruhig ist. Es soll sich bewegen, atmen, fressen und trinken koennen, ohne dass das Halsband scheuert oder ueber den Kopf rutscht. Nach Fellpflege, Wachstum, Gewichtsveraenderung oder einer neuen Position erneut pruefen.' },
38
+ ];
39
+ const schemas: PetCollarSizeCalculatorLocaleContent['schemas'] = [
40
+ { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'LifestyleApplication', operatingSystem: 'Web', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' } } as WithContext<SoftwareApplication>,
41
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) } as WithContext<FAQPage>,
42
+ { '@context': 'https://schema.org', '@type': 'HowTo', name: title, step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) } as WithContext<HowTo>,
43
+ ];
44
+ export const content: PetCollarSizeCalculatorLocaleContent = { slug, title, description, ui, seo, faq, bibliography, howTo, schemas };