@jjlmoya/utils-pets 1.18.0 → 1.19.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 +1 -1
- package/src/category/index.ts +2 -1
- package/src/data.ts +2 -0
- package/src/entries.ts +4 -1
- package/src/layouts/PreviewLayout.astro +2 -0
- package/src/pages/[locale]/[slug].astro +14 -5
- package/src/tests/diacritics_density.test.ts +1 -1
- package/src/tests/faq_count.test.ts +1 -1
- package/src/tests/inverted_punctuation.test.ts +1 -1
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/script_density.test.ts +1 -1
- package/src/tests/title_quality.test.ts +1 -0
- package/src/tests/tool_validation.test.ts +4 -4
- package/src/tests/translation_copy.test.ts +14 -15
- package/src/tool/petGestation/i18n/en.ts +1 -0
- package/src/tool/petGestation/i18n/es.ts +1 -0
- package/src/tool/petToxicity/bibliography.astro +6 -0
- package/src/tool/petToxicity/bibliography.ts +16 -0
- package/src/tool/petToxicity/component.astro +53 -0
- package/src/tool/petToxicity/controller.ts +117 -0
- package/src/tool/petToxicity/dom-views.ts +61 -0
- package/src/tool/petToxicity/entry.ts +33 -0
- package/src/tool/petToxicity/evaluator.ts +23 -0
- package/src/tool/petToxicity/i18n/de.ts +181 -0
- package/src/tool/petToxicity/i18n/en.ts +175 -0
- package/src/tool/petToxicity/i18n/es.ts +181 -0
- package/src/tool/petToxicity/i18n/fr.ts +181 -0
- package/src/tool/petToxicity/i18n/id.ts +182 -0
- package/src/tool/petToxicity/i18n/it.ts +181 -0
- package/src/tool/petToxicity/i18n/ja.ts +181 -0
- package/src/tool/petToxicity/i18n/ko.ts +181 -0
- package/src/tool/petToxicity/i18n/nl.ts +182 -0
- package/src/tool/petToxicity/i18n/pl.ts +182 -0
- package/src/tool/petToxicity/i18n/pt.ts +181 -0
- package/src/tool/petToxicity/i18n/ru.ts +182 -0
- package/src/tool/petToxicity/i18n/sv.ts +182 -0
- package/src/tool/petToxicity/i18n/tr.ts +182 -0
- package/src/tool/petToxicity/i18n/zh.ts +181 -0
- package/src/tool/petToxicity/index.ts +11 -0
- package/src/tool/petToxicity/logic.test.ts +32 -0
- package/src/tool/petToxicity/logic.ts +192 -0
- package/src/tool/petToxicity/pet-food-toxicity-checker.css +450 -0
- package/src/tool/petToxicity/seo.astro +15 -0
- package/src/tool/petToxicity/storage.ts +37 -0
- package/src/tool/petToxicity/ui.ts +33 -0
- package/src/tools.ts +3 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -2,10 +2,11 @@ import type { PetCategoryEntry } from '../types';
|
|
|
2
2
|
import { petAge } from '../tool/petAge/entry';
|
|
3
3
|
import { petRation } from '../tool/petRation/entry';
|
|
4
4
|
import { petGestation } from '../tool/petGestation/entry';
|
|
5
|
+
import { petToxicity } from '../tool/petToxicity/entry';
|
|
5
6
|
|
|
6
7
|
export const petsCategory: PetCategoryEntry = {
|
|
7
8
|
icon: 'mdi:paw',
|
|
8
|
-
tools: [petAge, petRation, petGestation],
|
|
9
|
+
tools: [petAge, petRation, petGestation, petToxicity],
|
|
9
10
|
i18n: {
|
|
10
11
|
en: () => import('./i18n/en').then((m) => m.content),
|
|
11
12
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/data.ts
CHANGED
|
@@ -2,10 +2,12 @@ export { petsCategory } from './category';
|
|
|
2
2
|
export { petAge } from './tool/petAge';
|
|
3
3
|
export { petRation } from './tool/petRation';
|
|
4
4
|
export { petGestation } from './tool/petGestation';
|
|
5
|
+
export { petToxicity } from './tool/petToxicity';
|
|
5
6
|
|
|
6
7
|
export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge';
|
|
7
8
|
export type { PetRationUI, PetRationLocaleContent } from './tool/petRation';
|
|
8
9
|
export type { PetGestationUI, PetGestationLocaleContent } from './tool/petGestation';
|
|
10
|
+
export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity';
|
|
9
11
|
|
|
10
12
|
export type {
|
|
11
13
|
KnownLocale,
|
package/src/entries.ts
CHANGED
|
@@ -2,8 +2,11 @@ export { petAge } from './tool/petAge/entry';
|
|
|
2
2
|
export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge/entry';
|
|
3
3
|
export { petRation } from './tool/petRation/entry';
|
|
4
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';
|
|
5
7
|
export { petsCategory } from './category';
|
|
6
8
|
import { petAge } from './tool/petAge/entry';
|
|
7
9
|
import { petRation } from './tool/petRation/entry';
|
|
8
10
|
import { petGestation } from './tool/petGestation/entry';
|
|
9
|
-
|
|
11
|
+
import { petToxicity } from './tool/petToxicity/entry';
|
|
12
|
+
export const ALL_ENTRIES = [petAge, petRation, petGestation, petToxicity];
|
|
@@ -15,7 +15,8 @@ export async function getStaticPaths() {
|
|
|
15
15
|
const paths = [];
|
|
16
16
|
|
|
17
17
|
for (const { entry, Component: lazyComp } of ALL_TOOLS) {
|
|
18
|
-
const { default:
|
|
18
|
+
const componentLoader = lazyComp as () => Promise<{ default: ToolComponent }>;
|
|
19
|
+
const { default: Component } = await componentLoader();
|
|
19
20
|
const localeEntries = Object.entries(entry.i18n) as [
|
|
20
21
|
KnownLocale,
|
|
21
22
|
() => Promise<ToolLocaleContent>,
|
|
@@ -71,7 +72,7 @@ interface NavItem {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
interface Props {
|
|
74
|
-
Component:
|
|
75
|
+
Component: ToolComponent;
|
|
75
76
|
locale: KnownLocale;
|
|
76
77
|
content: ToolLocaleContent;
|
|
77
78
|
localeUrls: Partial<Record<KnownLocale, string>>;
|
|
@@ -79,11 +80,14 @@ interface Props {
|
|
|
79
80
|
englishSlug: string;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
type ToolComponent = (props: { ui: Record<string, string> }) => unknown;
|
|
84
|
+
|
|
85
|
+
const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props as Props;
|
|
83
86
|
|
|
84
87
|
const cssFiles = import.meta.glob("../../tool/*/*.css", { query: "?raw", import: "default" });
|
|
85
88
|
const cssKey = Object.keys(cssFiles).find((k) => k.endsWith(`/${englishSlug}.css`));
|
|
86
|
-
const
|
|
89
|
+
const cssLoader = cssKey ? cssFiles[cssKey] : undefined;
|
|
90
|
+
const toolCss = cssLoader ? await cssLoader() as string : "";
|
|
87
91
|
|
|
88
92
|
const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
|
|
89
93
|
|
|
@@ -132,7 +136,7 @@ const titleBase = words.slice(1).join(" ") || "";
|
|
|
132
136
|
</section>
|
|
133
137
|
|
|
134
138
|
<section class="section-faq">
|
|
135
|
-
<FAQSection items={content.faq}
|
|
139
|
+
<FAQSection items={content.faq} />
|
|
136
140
|
</section>
|
|
137
141
|
|
|
138
142
|
<section class="section-bibliography">
|
|
@@ -146,17 +150,22 @@ const titleBase = words.slice(1).join(" ") || "";
|
|
|
146
150
|
display: flex;
|
|
147
151
|
flex-direction: column;
|
|
148
152
|
gap: 2rem;
|
|
153
|
+
min-width: 0;
|
|
154
|
+
width: 100%;
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
.section-tool {
|
|
152
158
|
max-width: 1200px;
|
|
153
159
|
margin: 0 auto;
|
|
160
|
+
min-width: 0;
|
|
154
161
|
width: 100%;
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
.section-seo,
|
|
158
165
|
.section-faq,
|
|
159
166
|
.section-bibliography {
|
|
167
|
+
min-width: 0;
|
|
168
|
+
width: 100%;
|
|
160
169
|
padding-top: 2rem;
|
|
161
170
|
border-top: 1px solid var(--border-color);
|
|
162
171
|
}
|
|
@@ -96,7 +96,7 @@ describe('Diacritics density validation', () => {
|
|
|
96
96
|
if (!loader) return;
|
|
97
97
|
|
|
98
98
|
const content = await loader();
|
|
99
|
-
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
99
|
+
const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
|
|
100
100
|
const rule = DIACRITIC_RULES[typedLocale];
|
|
101
101
|
const letters = letterCount(text);
|
|
102
102
|
const matches = diacriticCount(text, typedLocale);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import * as DATA from '../data';
|
|
3
3
|
|
|
4
|
-
const TOOLS = [DATA.petAge, DATA.petRation, DATA.petGestation];
|
|
4
|
+
const TOOLS = [DATA.petAge, DATA.petRation, DATA.petGestation, DATA.petToxicity];
|
|
5
5
|
|
|
6
6
|
describe('FAQ Content Validation', () => {
|
|
7
7
|
TOOLS.forEach((entry) => {
|
|
@@ -60,7 +60,7 @@ describe('Inverted punctuation validation', () => {
|
|
|
60
60
|
|
|
61
61
|
const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
|
|
62
62
|
const content = await loader();
|
|
63
|
-
const strings = translatableStrings(content as Record<string, unknown>);
|
|
63
|
+
const strings = translatableStrings(content as unknown as Record<string, unknown>);
|
|
64
64
|
const missingQuestions = strings.flatMap((text) =>
|
|
65
65
|
findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
|
|
66
66
|
);
|
|
@@ -73,7 +73,7 @@ describe('Native script density validation', () => {
|
|
|
73
73
|
|
|
74
74
|
const content = await loader();
|
|
75
75
|
const rule = SCRIPT_RULES[typedLocale];
|
|
76
|
-
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
76
|
+
const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
|
|
77
77
|
const letters = letterCount(text);
|
|
78
78
|
const matches = scriptCount(text, typedLocale);
|
|
79
79
|
const ratio = scriptRatio(text, typedLocale);
|
|
@@ -71,7 +71,7 @@ describe('Tool Validation Suite', () => {
|
|
|
71
71
|
expect(content.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
|
72
72
|
|
|
73
73
|
if (locale === 'es') {
|
|
74
|
-
const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas', 'calculadora-gestacion-mascotas'];
|
|
74
|
+
const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas', 'calculadora-gestacion-mascotas', 'buscador-alimentos-toxicos-perros-gatos'];
|
|
75
75
|
expect(validSlugs).toContain(content.slug);
|
|
76
76
|
}
|
|
77
77
|
});
|
|
@@ -96,12 +96,12 @@ describe('Tool Validation Suite', () => {
|
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
describe('Library Registration', () => {
|
|
99
|
-
it('should have
|
|
100
|
-
expect(ALL_TOOLS.length).toBe(
|
|
99
|
+
it('should have 4 tools in ALL_TOOLS', () => {
|
|
100
|
+
expect(ALL_TOOLS.length).toBe(4);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
it('should have all tools in petsCategory', () => {
|
|
104
|
-
expect(petsCategory.tools.length).toBe(
|
|
104
|
+
expect(petsCategory.tools.length).toBe(4);
|
|
105
105
|
ALL_TOOLS.forEach(({ entry }) => {
|
|
106
106
|
const exists = petsCategory.tools.some((t: any) => t.id === entry.id);
|
|
107
107
|
expect(exists).toBe(true);
|
|
@@ -91,6 +91,19 @@ function copySimilarity(left: string, right: string): number {
|
|
|
91
91
|
return (2 * shared) / (leftTotal + rightTotal);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
function findViolations(locales: string[], corpora: Map<string, string>): string[] {
|
|
95
|
+
const violations: string[] = [];
|
|
96
|
+
for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
|
|
97
|
+
for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
|
|
98
|
+
const left = locales[leftIndex] ?? '';
|
|
99
|
+
const right = locales[rightIndex] ?? '';
|
|
100
|
+
const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
|
|
101
|
+
if (similarity >= COPY_THRESHOLD) violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return violations;
|
|
105
|
+
}
|
|
106
|
+
|
|
94
107
|
describe('Locales must not copy another locale wholesale', () => {
|
|
95
108
|
ALL_ENTRIES.forEach((entry) => {
|
|
96
109
|
it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
|
|
@@ -102,23 +115,9 @@ describe('Locales must not copy another locale wholesale', () => {
|
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
const locales = [...corpora.keys()];
|
|
105
|
-
const violations
|
|
106
|
-
|
|
107
|
-
for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
|
|
108
|
-
for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
|
|
109
|
-
const left = locales[leftIndex];
|
|
110
|
-
const right = locales[rightIndex];
|
|
111
|
-
const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
|
|
112
|
-
|
|
113
|
-
if (similarity >= COPY_THRESHOLD) {
|
|
114
|
-
violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
+
const violations = findViolations(locales, corpora);
|
|
118
119
|
|
|
119
120
|
expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
|
|
120
121
|
});
|
|
121
122
|
});
|
|
122
123
|
});
|
|
123
|
-
|
|
124
|
-
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bibliography } from '../bibliography';
|
|
2
2
|
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
3
3
|
import type { PetGestationLocaleContent } from '../index';
|
|
4
|
+
import type { PetGestationUI } from '../ui';
|
|
4
5
|
|
|
5
6
|
const slug = 'pet-gestation-calculator';
|
|
6
7
|
const title = 'Pet Gestation Calculator';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bibliography } from '../bibliography';
|
|
2
2
|
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
3
3
|
import type { PetGestationLocaleContent } from '../index';
|
|
4
|
+
import type { PetGestationUI } from '../ui';
|
|
4
5
|
|
|
5
6
|
const slug = 'calculadora-gestacion-mascotas';
|
|
6
7
|
const title = 'Calculadora de gestación de mascotas';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'ASPCA Poison Control: People Foods to Avoid Feeding Your Pets',
|
|
6
|
+
url: 'https://www.aspca.org/pet-care/aspca-poison-control/people-foods-avoid-feeding-your-pets',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'FDA: Potentially Dangerous Items for Your Pet',
|
|
10
|
+
url: 'https://www.fda.gov/animal-veterinary/animal-health-literacy/potentially-dangerous-items-your-pet',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'Merck Veterinary Manual: Garlic and Onion Toxicosis in Animals',
|
|
14
|
+
url: 'https://www.merckvetmanual.com/toxicology/food-hazards/garlic-and-onion-allium-spp-toxicosis-in-animals',
|
|
15
|
+
},
|
|
16
|
+
];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './pet-food-toxicity-checker.css';
|
|
3
|
+
import { getFoodEvaluation } from './logic';
|
|
4
|
+
import { renderFoodMenu, renderResult, renderScene, renderSpeciesMenu } from './dom-views';
|
|
5
|
+
import { petToxicity } from './entry';
|
|
6
|
+
|
|
7
|
+
const content = await petToxicity.i18n.en?.();
|
|
8
|
+
if (!content) return null;
|
|
9
|
+
const { ui } = content;
|
|
10
|
+
const defaultSpecies = 'dog';
|
|
11
|
+
const defaultFood = 'chocolate';
|
|
12
|
+
const evaluation = getFoodEvaluation(defaultSpecies, defaultFood);
|
|
13
|
+
const config = { ui, defaultSpecies, defaultFood };
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<section class="pet-toxicity" data-pet-toxicity-root aria-label={content.title}>
|
|
17
|
+
<div class="pet-toxicity-journey">
|
|
18
|
+
<span class="pet-toxicity-journey-mark" aria-hidden="true">!</span>
|
|
19
|
+
<p>{ui.journeyHint}</p>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="pet-toxicity-workbench">
|
|
22
|
+
<div class="pet-toxicity-controls">
|
|
23
|
+
<div class="pet-toxicity-control">
|
|
24
|
+
<span class="pet-toxicity-label">{ui.speciesLabel}</span>
|
|
25
|
+
<div class="pet-toxicity-select">
|
|
26
|
+
<button class="pet-toxicity-select-trigger" type="button" data-menu-trigger data-species-trigger aria-haspopup="listbox" aria-expanded="false"><span data-trigger-value>{ui.speciesDog}</span><span class="pet-toxicity-chevron" aria-hidden="true"></span></button>
|
|
27
|
+
<div class="pet-toxicity-menu" data-menu data-species-menu role="listbox" hidden>{renderSpeciesMenu(defaultSpecies, ui)}</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="pet-toxicity-control">
|
|
31
|
+
<span class="pet-toxicity-label">{ui.foodLabel}</span>
|
|
32
|
+
<div class="pet-toxicity-select">
|
|
33
|
+
<button class="pet-toxicity-select-trigger" type="button" data-menu-trigger data-food-trigger aria-haspopup="listbox" aria-expanded="false"><span data-trigger-value>{getFoodEvaluation(defaultSpecies, defaultFood)?.food.name ?? ui.foodPlaceholder}</span><span class="pet-toxicity-chevron" aria-hidden="true"></span></button>
|
|
34
|
+
<div class="pet-toxicity-menu" data-menu data-food-menu role="listbox" hidden>{renderFoodMenu([{ id: 'chocolate', name: 'Chocolate and caffeine', detail: 'Chocolate, coffee, tea, energy drinks' }], defaultFood)}</div>
|
|
35
|
+
</div>
|
|
36
|
+
<small class="pet-toxicity-hint">{ui.foodMenuHint}</small>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="pet-toxicity-visual" data-scene>{renderScene(defaultSpecies, evaluation, ui)}</div>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="pet-toxicity-result" data-result aria-live="polite">{renderResult(evaluation, ui)}</div>
|
|
42
|
+
<div class="pet-toxicity-emergency"><strong>{ui.emergencyTitle}</strong><span>{ui.emergencyText}</span></div>
|
|
43
|
+
<div class="pet-toxicity-sources"><span class="pet-toxicity-label">{ui.sourceLabel}</span><p>{ui.sourceText}</p></div>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<script is:inline type="application/json" data-pet-toxicity-config set:html={JSON.stringify(config)}></script>
|
|
47
|
+
<script>
|
|
48
|
+
import { mountPetToxicity } from './controller';
|
|
49
|
+
|
|
50
|
+
const root = document.querySelector<HTMLElement>('[data-pet-toxicity-root]');
|
|
51
|
+
const configElement = document.querySelector<HTMLScriptElement>('[data-pet-toxicity-config]');
|
|
52
|
+
if (root && configElement?.textContent) mountPetToxicity(root, JSON.parse(configElement.textContent));
|
|
53
|
+
</script>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { getFoodEvaluation, getFoodOptions } from './logic';
|
|
2
|
+
import type { FoodId, PetSpecies } from './logic';
|
|
3
|
+
import { renderFoodMenu, renderResult, renderScene, renderSpeciesMenu } from './dom-views';
|
|
4
|
+
import { loadSavedState, saveState } from './storage';
|
|
5
|
+
import type { PetToxicityUI } from './ui';
|
|
6
|
+
|
|
7
|
+
interface MountPayload {
|
|
8
|
+
ui: PetToxicityUI;
|
|
9
|
+
defaultSpecies: PetSpecies;
|
|
10
|
+
defaultFood: FoodId;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ToolState {
|
|
14
|
+
species: PetSpecies;
|
|
15
|
+
foodId: FoodId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function findElement<T extends Element>(root: HTMLElement, selector: string): T {
|
|
19
|
+
const element = root.querySelector<T>(selector);
|
|
20
|
+
if (!element) throw new Error(`Missing pet toxicity element: ${selector}`);
|
|
21
|
+
return element;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function closeMenus(root: HTMLElement): void {
|
|
25
|
+
root.querySelectorAll<HTMLElement>('[data-menu]').forEach((menu) => { menu.hidden = true; });
|
|
26
|
+
root.querySelectorAll<HTMLElement>('[data-menu-trigger]').forEach((trigger) => { trigger.setAttribute('aria-expanded', 'false'); });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toggleMenu(root: HTMLElement, menu: HTMLElement, trigger: HTMLElement): void {
|
|
30
|
+
const shouldOpen = menu.hidden;
|
|
31
|
+
closeMenus(root);
|
|
32
|
+
menu.hidden = !shouldOpen;
|
|
33
|
+
trigger.setAttribute('aria-expanded', String(shouldOpen));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function labelForSpecies(species: PetSpecies, ui: PetToxicityUI): string {
|
|
37
|
+
return species === 'dog' ? ui.speciesDog : ui.speciesCat;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setupKeyboard(root: HTMLElement): void {
|
|
41
|
+
root.querySelectorAll<HTMLElement>('[data-menu-trigger]').forEach((trigger) => {
|
|
42
|
+
trigger.addEventListener('keydown', (event) => {
|
|
43
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
trigger.click();
|
|
46
|
+
}
|
|
47
|
+
if (event.key === 'Escape') closeMenus(root);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ViewElements {
|
|
53
|
+
speciesTrigger: HTMLButtonElement;
|
|
54
|
+
speciesMenu: HTMLElement;
|
|
55
|
+
foodTrigger: HTMLButtonElement;
|
|
56
|
+
foodMenu: HTMLElement;
|
|
57
|
+
scene: HTMLElement;
|
|
58
|
+
result: HTMLElement;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderState(elements: ViewElements, state: ToolState, payload: MountPayload): void {
|
|
62
|
+
const options = getFoodOptions(state.species);
|
|
63
|
+
if (!options.some((option) => option.id === state.foodId)) state.foodId = options[0]?.id ?? payload.defaultFood;
|
|
64
|
+
const evaluation = getFoodEvaluation(state.species, state.foodId);
|
|
65
|
+
const selectedFood = options.find((option) => option.id === state.foodId);
|
|
66
|
+
elements.speciesTrigger.querySelector('[data-trigger-value]')!.textContent = labelForSpecies(state.species, payload.ui);
|
|
67
|
+
elements.foodTrigger.querySelector('[data-trigger-value]')!.textContent = selectedFood?.name ?? payload.ui.foodPlaceholder;
|
|
68
|
+
elements.speciesMenu.innerHTML = renderSpeciesMenu(state.species, payload.ui);
|
|
69
|
+
elements.foodMenu.innerHTML = renderFoodMenu(options, state.foodId);
|
|
70
|
+
elements.scene.innerHTML = renderScene(state.species, evaluation, payload.ui);
|
|
71
|
+
elements.result.innerHTML = renderResult(evaluation, payload.ui);
|
|
72
|
+
saveState(state);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function bindSelection(root: HTMLElement, elements: ViewElements, state: ToolState, render: () => void): void {
|
|
76
|
+
elements.speciesTrigger.addEventListener('click', () => toggleMenu(root, elements.speciesMenu, elements.speciesTrigger));
|
|
77
|
+
elements.foodTrigger.addEventListener('click', () => toggleMenu(root, elements.foodMenu, elements.foodTrigger));
|
|
78
|
+
root.addEventListener('click', (event) => {
|
|
79
|
+
const target = event.target as HTMLElement;
|
|
80
|
+
const speciesOption = target.closest<HTMLElement>('[data-species-id]');
|
|
81
|
+
const foodOption = target.closest<HTMLElement>('[data-food-id]');
|
|
82
|
+
if (speciesOption) selectSpecies(speciesOption, root, state, render);
|
|
83
|
+
if (foodOption) selectFood(foodOption, root, state, render);
|
|
84
|
+
});
|
|
85
|
+
document.addEventListener('click', (event) => {
|
|
86
|
+
if (!root.contains(event.target as Node)) closeMenus(root);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function selectSpecies(option: HTMLElement, root: HTMLElement, state: ToolState, render: () => void): void {
|
|
91
|
+
state.species = option.dataset.speciesId as PetSpecies;
|
|
92
|
+
closeMenus(root);
|
|
93
|
+
render();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function selectFood(option: HTMLElement, root: HTMLElement, state: ToolState, render: () => void): void {
|
|
97
|
+
state.foodId = option.dataset.foodId as FoodId;
|
|
98
|
+
closeMenus(root);
|
|
99
|
+
render();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function mountPetToxicity(root: HTMLElement, payload: MountPayload): void {
|
|
103
|
+
const saved = loadSavedState();
|
|
104
|
+
const state: ToolState = { species: saved?.species ?? payload.defaultSpecies, foodId: saved?.foodId ?? payload.defaultFood };
|
|
105
|
+
const elements: ViewElements = {
|
|
106
|
+
speciesTrigger: findElement<HTMLButtonElement>(root, '[data-species-trigger]'),
|
|
107
|
+
speciesMenu: findElement<HTMLElement>(root, '[data-species-menu]'),
|
|
108
|
+
foodTrigger: findElement<HTMLButtonElement>(root, '[data-food-trigger]'),
|
|
109
|
+
foodMenu: findElement<HTMLElement>(root, '[data-food-menu]'),
|
|
110
|
+
scene: findElement<HTMLElement>(root, '[data-scene]'),
|
|
111
|
+
result: findElement<HTMLElement>(root, '[data-result]'),
|
|
112
|
+
};
|
|
113
|
+
const render = () => renderState(elements, state, payload);
|
|
114
|
+
bindSelection(root, elements, state, render);
|
|
115
|
+
setupKeyboard(root);
|
|
116
|
+
render();
|
|
117
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getRiskPresentation } from './evaluator';
|
|
2
|
+
import type { RiskTone } from './evaluator';
|
|
3
|
+
import type { FoodEvaluation, FoodOption, PetSpecies } from './logic';
|
|
4
|
+
import type { PetToxicityUI } from './ui';
|
|
5
|
+
|
|
6
|
+
function escapeHtml(value: string): string {
|
|
7
|
+
return value.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ?? character);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function selectedAttribute(selected: boolean): string {
|
|
11
|
+
return selected ? ' aria-selected="true" data-selected="true"' : ' aria-selected="false"';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderSpeciesMenu(species: PetSpecies, ui: PetToxicityUI): string {
|
|
15
|
+
const options = [
|
|
16
|
+
{ id: 'dog', name: ui.speciesDog, detail: ui.speciesDogMeta },
|
|
17
|
+
{ id: 'cat', name: ui.speciesCat, detail: ui.speciesCatMeta },
|
|
18
|
+
];
|
|
19
|
+
return options.map((option) => `<button class="pet-toxicity-menu-option" type="button" role="option" data-species-id="${option.id}"${selectedAttribute(option.id === species)}><span>${escapeHtml(option.name)}</span><small>${escapeHtml(option.detail)}</small></button>`).join('');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function renderFoodMenu(options: FoodOption[], selectedId: string): string {
|
|
23
|
+
return options.map((option) => `<button class="pet-toxicity-menu-option" type="button" role="option" data-food-id="${option.id}"${selectedAttribute(option.id === selectedId)}><span>${escapeHtml(option.name)}</span><small>${escapeHtml(option.detail)}</small></button>`).join('');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderMarkers(evaluation: FoodEvaluation | null): string {
|
|
27
|
+
const tone = evaluation?.level ?? 'empty';
|
|
28
|
+
const markerClass = evaluation ? `pet-toxicity-marker pet-toxicity-marker-${tone}` : 'pet-toxicity-marker pet-toxicity-marker-empty';
|
|
29
|
+
return [0, 1, 2, 3, 4].map((index) => `<circle class="${markerClass}" cx="${74 + index * 47}" cy="${82 + (index % 2) * 34}" r="${evaluation ? 7 + index : 5}" />`).join('');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function renderScene(species: PetSpecies, evaluation: FoodEvaluation | null, ui: PetToxicityUI): string {
|
|
33
|
+
const speciesName = species === 'dog' ? ui.speciesDog : ui.speciesCat;
|
|
34
|
+
const selectedName = evaluation?.food.name ?? ui.sceneReady;
|
|
35
|
+
const stateText = evaluation ? ui.sceneSelected : ui.resultEmpty;
|
|
36
|
+
return `<div class="pet-toxicity-scene-card"><div class="pet-toxicity-scene-copy"><span class="pet-toxicity-eyebrow">${escapeHtml(ui.sceneLabel)}</span><strong>${escapeHtml(selectedName)}</strong><span>${escapeHtml(speciesName)} · ${escapeHtml(stateText)}</span></div><svg class="pet-toxicity-scene" viewBox="0 0 320 180" role="img" aria-label="${escapeHtml(selectedName)} risk scene"><rect class="pet-toxicity-scene-paper" x="14" y="14" width="292" height="152" rx="18" /><path class="pet-toxicity-scene-line" d="M38 122 C80 46 124 146 166 66 S246 50 282 118" /><path class="pet-toxicity-scene-line-thin" d="M42 138 C92 84 132 138 174 98 S240 82 280 136" />${renderMarkers(evaluation)}<circle class="pet-toxicity-scene-seal" cx="258" cy="52" r="20" /><path class="pet-toxicity-scene-seal-mark" d="M258 43v12" /><circle class="pet-toxicity-scene-seal-dot" cx="258" cy="62" r="1.7" /></svg></div>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderSignIcon(index: number): string {
|
|
40
|
+
const paths = [
|
|
41
|
+
'<circle cx="8" cy="8" r="6" /><path d="M8 4.5v4" /><circle cx="8" cy="11.5" r=".7" />',
|
|
42
|
+
'<path d="M1.5 9h2.5l1.5-4 2.5 6 1.6-3h2.3" />',
|
|
43
|
+
'<path d="m8 1.5 6 11H2l6-11Z" /><path d="M8 5v3" /><circle cx="8" cy="10.5" r=".7" />',
|
|
44
|
+
];
|
|
45
|
+
return `<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">${paths[index % paths.length]}</svg>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderList(items: string[]): string {
|
|
49
|
+
return `<ul class="pet-toxicity-sign-list">${items.map((item, index) => `<li><span class="pet-toxicity-sign-icon">${renderSignIcon(index)}</span><span>${escapeHtml(item)}</span></li>`).join('')}</ul>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderRiskMeter(tone: RiskTone, label: string): string {
|
|
53
|
+
const activeCount = { critical: 3, high: 2, caution: 1, unknown: 0 }[tone];
|
|
54
|
+
return `<span class="pet-toxicity-risk-meter" role="img" aria-label="${escapeHtml(label)}"><span class="${activeCount >= 1 ? 'is-active' : ''}"></span><span class="${activeCount >= 2 ? 'is-active' : ''}"></span><span class="${activeCount >= 3 ? 'is-active' : ''}"></span></span>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderResult(evaluation: FoodEvaluation | null, ui: PetToxicityUI): string {
|
|
58
|
+
if (!evaluation) return `<div class="pet-toxicity-result-empty">${escapeHtml(ui.resultEmpty)}</div>`;
|
|
59
|
+
const risk = getRiskPresentation(evaluation.level, ui);
|
|
60
|
+
return `<article class="pet-toxicity-result-card pet-toxicity-result-card-${risk.tone}"><div class="pet-toxicity-result-head"><span class="pet-toxicity-eyebrow">${escapeHtml(ui.resultEyebrow)}</span><span class="pet-toxicity-risk-status"><span class="pet-toxicity-risk pet-toxicity-risk-${risk.tone}">${escapeHtml(risk.label)}</span>${renderRiskMeter(risk.tone, `${ui.riskLabel}: ${risk.label}`)}</span></div><h3>${escapeHtml(evaluation.food.name)}</h3><p class="pet-toxicity-summary">${escapeHtml(evaluation.summary)}</p><div class="pet-toxicity-result-grid"><div><span class="pet-toxicity-label">${escapeHtml(ui.whyLabel)}</span><p>${escapeHtml(evaluation.why)}</p></div><div><span class="pet-toxicity-label">${escapeHtml(ui.signsLabel)}</span>${renderList(evaluation.signs)}</div><div class="pet-toxicity-action"><span class="pet-toxicity-label">${escapeHtml(ui.actionLabel)}</span><p>${escapeHtml(evaluation.action)}</p></div></div><div class="pet-toxicity-callout"><strong>${escapeHtml(ui.callVetLabel)}</strong><span>${escapeHtml(ui.callVetText)}</span></div></article>`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PetToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { PetToxicityUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { PetToxicityUI } from './ui';
|
|
5
|
+
|
|
6
|
+
export type PetToxicityLocaleContent = ToolLocaleContent<PetToxicityUI>;
|
|
7
|
+
|
|
8
|
+
export const petToxicity: PetToolEntry<PetToxicityUI> = {
|
|
9
|
+
id: 'pet-toxicity',
|
|
10
|
+
icons: {
|
|
11
|
+
bg: 'mdi:paw',
|
|
12
|
+
fg: 'mdi:alert-octagon',
|
|
13
|
+
},
|
|
14
|
+
i18n: {
|
|
15
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
16
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
17
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
18
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
19
|
+
id: () => import('./i18n/id').then((module) => module.content),
|
|
20
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
21
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
22
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
23
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
24
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
25
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
26
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
27
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
28
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
29
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export { bibliography } from './bibliography';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PetToxicityUI } from './ui';
|
|
2
|
+
import type { FoodEvaluation, RiskLevel } from './logic';
|
|
3
|
+
|
|
4
|
+
export type RiskTone = 'critical' | 'high' | 'caution' | 'unknown';
|
|
5
|
+
|
|
6
|
+
export interface RiskPresentation {
|
|
7
|
+
label: string;
|
|
8
|
+
tone: RiskTone;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getRiskPresentation(level: RiskLevel, ui: PetToxicityUI): RiskPresentation {
|
|
12
|
+
const labels: Record<RiskLevel, string> = {
|
|
13
|
+
critical: ui.riskCritical,
|
|
14
|
+
high: ui.riskHigh,
|
|
15
|
+
caution: ui.riskCaution,
|
|
16
|
+
unknown: ui.riskUnknown,
|
|
17
|
+
};
|
|
18
|
+
return { label: labels[level], tone: level };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getResultTitle(evaluation: FoodEvaluation | null, ui: PetToxicityUI): string {
|
|
22
|
+
return evaluation ? evaluation.food.name : ui.resultEmpty;
|
|
23
|
+
}
|