@jjlmoya/utils-nature 1.14.0 → 1.15.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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/fr.ts +6 -6
  3. package/src/category/i18n/ru.ts +5 -5
  4. package/src/layouts/PreviewLayout.astro +1 -1
  5. package/src/tests/diacritics_density.test.ts +118 -0
  6. package/src/tests/inverted_punctuation.test.ts +84 -0
  7. package/src/tests/no_en_dash.test.ts +71 -0
  8. package/src/tests/script_density.test.ts +94 -0
  9. package/src/tool/cricketThermometer/i18n/de.ts +4 -4
  10. package/src/tool/cricketThermometer/i18n/en.ts +1 -1
  11. package/src/tool/cricketThermometer/i18n/es.ts +1 -1
  12. package/src/tool/cricketThermometer/i18n/fr.ts +7 -7
  13. package/src/tool/cricketThermometer/i18n/id.ts +1 -1
  14. package/src/tool/cricketThermometer/i18n/it.ts +1 -1
  15. package/src/tool/cricketThermometer/i18n/ja.ts +1 -1
  16. package/src/tool/cricketThermometer/i18n/ko.ts +1 -1
  17. package/src/tool/cricketThermometer/i18n/nl.ts +1 -1
  18. package/src/tool/cricketThermometer/i18n/pl.ts +4 -4
  19. package/src/tool/cricketThermometer/i18n/pt.ts +1 -1
  20. package/src/tool/cricketThermometer/i18n/ru.ts +6 -6
  21. package/src/tool/cricketThermometer/i18n/sv.ts +1 -1
  22. package/src/tool/cricketThermometer/i18n/tr.ts +1 -1
  23. package/src/tool/cricketThermometer/i18n/zh.ts +6 -6
  24. package/src/tool/digitalCarbon/digital-carbon-footprint-calculator.css +57 -1
  25. package/src/tool/digitalCarbon/i18n/de.ts +2 -2
  26. package/src/tool/digitalCarbon/i18n/fr.ts +4 -4
  27. package/src/tool/digitalCarbon/i18n/ru.ts +1 -1
  28. package/src/tool/rainHarvester/i18n/de.ts +3 -3
  29. package/src/tool/rainHarvester/i18n/fr.ts +1 -1
  30. package/src/tool/rainHarvester/i18n/pl.ts +3 -3
  31. package/src/tool/rainHarvester/i18n/ru.ts +6 -6
  32. package/src/tool/rainHarvester/i18n/zh.ts +3 -3
  33. package/src/tool/seedCalculator/i18n/fr.ts +1 -1
  34. package/src/tool/seedCalculator/i18n/pl.ts +1 -1
  35. package/src/tool/seedCalculator/i18n/ru.ts +4 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-nature",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -8,7 +8,7 @@ export const content: CategoryLocaleContent = {
8
8
  seo: [
9
9
  {
10
10
  type: 'title',
11
- text: 'Ingénierie Biologique et Durabilité : Données pour la Planète',
11
+ text: 'Ingénierie Biologique et Durabilité: Données pour la Planète',
12
12
  level: 2,
13
13
  },
14
14
  {
@@ -21,7 +21,7 @@ export const content: CategoryLocaleContent = {
21
21
  },
22
22
  {
23
23
  type: 'title',
24
- text: 'Agriculture de Précision : Étalonnage des Semis et Culture',
24
+ text: 'Agriculture de Précision: Étalonnage des Semis et Culture',
25
25
  level: 2,
26
26
  },
27
27
  {
@@ -30,7 +30,7 @@ export const content: CategoryLocaleContent = {
30
30
  },
31
31
  {
32
32
  type: 'title',
33
- text: 'Gestion des Ressources en Eau : Récupération d\'Eau de Pluie',
33
+ text: 'Gestion des Ressources en Eau: Récupération d\'Eau de Pluie',
34
34
  level: 2,
35
35
  },
36
36
  {
@@ -48,7 +48,7 @@ export const content: CategoryLocaleContent = {
48
48
  },
49
49
  {
50
50
  type: 'title',
51
- text: 'Durabilité Numérique : Empreinte Carbone Web',
51
+ text: 'Durabilité Numérique: Empreinte Carbone Web',
52
52
  level: 2,
53
53
  },
54
54
  {
@@ -71,7 +71,7 @@ export const content: CategoryLocaleContent = {
71
71
  },
72
72
  {
73
73
  type: 'title',
74
- text: 'Rotation des Cultures et Biodiversité : La Science Agricole Millénaire',
74
+ text: 'Rotation des Cultures et Biodiversité: La Science Agricole Millénaire',
75
75
  level: 2,
76
76
  },
77
77
  {
@@ -80,7 +80,7 @@ export const content: CategoryLocaleContent = {
80
80
  },
81
81
  {
82
82
  type: 'title',
83
- text: 'Conservation du Sol et des Nutriments : Analyse Pédologique',
83
+ text: 'Conservation du Sol et des Nutriments: Analyse Pédologique',
84
84
  level: 2,
85
85
  },
86
86
  {
@@ -13,7 +13,7 @@ export const content: CategoryLocaleContent = {
13
13
  },
14
14
  {
15
15
  type: 'paragraph',
16
- html: 'Связь с природой в 2026 году опосредована более глубоким техническим пониманием наших ресурсов. В этом разделе мы предлагаем <strong>бесплатные онлайн-инструменты</strong>, разработанные для оптимизации сельскохозяйственного производства в малом и крупном масштабе, управления жизненно важными водными ресурсами и измерения нашего невидимого воздействия на глобальную экосистему. <strong>Точное земледелие</strong> и устойчивое развитие это не просто концепции, а действия, основанные на точных расчётах.',
16
+ html: 'Связь с природой в 2026 году опосредована более глубоким техническим пониманием наших ресурсов. В этом разделе мы предлагаем <strong>бесплатные онлайн-инструменты</strong>, разработанные для оптимизации сельскохозяйственного производства в малом и крупном масштабе, управления жизненно важными водными ресурсами и измерения нашего невидимого воздействия на глобальную экосистему. <strong>Точное земледелие</strong> и устойчивое развитие - это не просто концепции, а действия, основанные на точных расчётах.',
17
17
  },
18
18
  {
19
19
  type: 'paragraph',
@@ -35,7 +35,7 @@ export const content: CategoryLocaleContent = {
35
35
  },
36
36
  {
37
37
  type: 'paragraph',
38
- html: 'Вода конечный ресурс. <strong>Коллектор дождевой воды</strong> рассчитывает, сколько литров воды вы можете собрать ежегодно, исходя из площади вашей кровли и истории осадков в вашем регионе. Этот инструмент жизненно важен для расчёта объёма накопительных баков и внедрения дополнительных систем полива для городских садов и сельских ферм.',
38
+ html: 'Вода - конечный ресурс. <strong>Коллектор дождевой воды</strong> рассчитывает, сколько литров воды вы можете собрать ежегодно, исходя из площади вашей кровли и истории осадков в вашем регионе. Этот инструмент жизненно важен для расчёта объёма накопительных баков и внедрения дополнительных систем полива для городских садов и сельских ферм.',
39
39
  },
40
40
  {
41
41
  type: 'title',
@@ -76,7 +76,7 @@ export const content: CategoryLocaleContent = {
76
76
  },
77
77
  {
78
78
  type: 'paragraph',
79
- html: 'Севооборот не средневековая традиция; это физиология растений. Разные культуры потребляют разные почвенные питательные вещества и создают разный бактериальный состав в ризосфере. Пшеница истощает азот; люцерна его фиксирует. Кукуруза требует много калия; овёс оставляет его доступным. Разумный севооборот (пшеница-люцерна-кукуруза-бобовые) восстанавливает природное плодородие без дорогих синтетических удобрений.',
79
+ html: 'Севооборот - не средневековая традиция; это физиология растений. Разные культуры потребляют разные почвенные питательные вещества и создают разный бактериальный состав в ризосфере. Пшеница истощает азот; люцерна его фиксирует. Кукуруза требует много калия; овёс оставляет его доступным. Разумный севооборот (пшеница-люцерна-кукуруза-бобовые) восстанавливает природное плодородие без дорогих синтетических удобрений.',
80
80
  },
81
81
  {
82
82
  type: 'title',
@@ -85,7 +85,7 @@ export const content: CategoryLocaleContent = {
85
85
  },
86
86
  {
87
87
  type: 'paragraph',
88
- html: 'Почва живая. Она содержит триллионы микробов, грибков и организмов, создающих структуру и плодородие. Эрозия от чрезмерной вспашки разрушает эту структуру; уплотнённая почва не впитывает воду; низкое содержание органического вещества снижает водоудерживающую способность. Анализ почвы (pH, питательные вещества, органическое вещество, текстура) это диагностика перед любым вмешательством.',
88
+ html: 'Почва живая. Она содержит триллионы микробов, грибков и организмов, создающих структуру и плодородие. Эрозия от чрезмерной вспашки разрушает эту структуру; уплотнённая почва не впитывает воду; низкое содержание органического вещества снижает водоудерживающую способность. Анализ почвы (pH, питательные вещества, органическое вещество, текстура) - это диагностика перед любым вмешательством.',
89
89
  },
90
90
  {
91
91
  type: 'title',
@@ -94,7 +94,7 @@ export const content: CategoryLocaleContent = {
94
94
  },
95
95
  {
96
96
  type: 'paragraph',
97
- html: 'Тренд 2026 года это <strong>Интернет естественных вещей (IoNT)</strong>. Датчики влажности, дроны мониторинга и программное обеспечение для управления средой демократизируют регенеративное земледелие. Эти инструменты дают вам возможность управлять своей средой не как ресурсом для эксплуатации, а как экосистемой, которую необходимо питать и защищать с помощью технического интеллекта.',
97
+ html: 'Тренд 2026 года - это <strong>Интернет естественных вещей (IoNT)</strong>. Датчики влажности, дроны мониторинга и программное обеспечение для управления средой демократизируют регенеративное земледелие. Эти инструменты дают вам возможность управлять своей средой не как ресурсом для эксплуатации, а как экосистемой, которую необходимо питать и защищать с помощью технического интеллекта.',
98
98
  },
99
99
  {
100
100
  type: 'stats',
@@ -78,7 +78,7 @@ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Ast
78
78
  transition:
79
79
  background-color 0.3s ease,
80
80
  color 0.3s ease;
81
- }
81
+ font-family: Inter, sans-serif;}
82
82
 
83
83
  main {
84
84
  padding: 0 2rem;
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ type LocaleWithDiacritics = keyof typeof DIACRITIC_RULES;
5
+
6
+ const DIACRITIC_RULES = {
7
+ de: {
8
+ language: 'German',
9
+ expectedCharacters: 'ä ö ü ß',
10
+ characters: /[äöüÄÖÜß]/g,
11
+ minPerThousandLetters: 0.1,
12
+ },
13
+ es: {
14
+ language: 'Spanish',
15
+ expectedCharacters: 'á é í ó ú ü ñ',
16
+ characters: /[áéíóúüñÁÉÍÓÚÜÑ]/g,
17
+ minPerThousandLetters: 0.1,
18
+ },
19
+ fr: {
20
+ language: 'French',
21
+ expectedCharacters: 'à â æ ç é è ê ë î ï ô œ ù û ü ÿ',
22
+ characters: /[àâæçéèêëîïôœùûüÿÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸ]/g,
23
+ minPerThousandLetters: 0.1,
24
+ },
25
+ it: {
26
+ language: 'Italian',
27
+ expectedCharacters: 'à è é ì í î ò ó ù ú',
28
+ characters: /[àèéìíîòóùúÀÈÉÌÍÎÒÓÙÚ]/g,
29
+ minPerThousandLetters: 0.1,
30
+ },
31
+ pl: {
32
+ language: 'Polish',
33
+ expectedCharacters: 'ą ć ę ł ń ó ś ź ż',
34
+ characters: /[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g,
35
+ minPerThousandLetters: 0.1,
36
+ },
37
+ pt: {
38
+ language: 'Portuguese',
39
+ expectedCharacters: 'á â ã à ç é ê í ó ô õ ú ü',
40
+ characters: /[áâãàçéêíóôõúüÁÂÃÀÇÉÊÍÓÔÕÚÜ]/g,
41
+ minPerThousandLetters: 0.1,
42
+ },
43
+ sv: {
44
+ language: 'Swedish',
45
+ expectedCharacters: 'å ä ö',
46
+ characters: /[åäöÅÄÖ]/g,
47
+ minPerThousandLetters: 0.1,
48
+ },
49
+ tr: {
50
+ language: 'Turkish',
51
+ expectedCharacters: 'ç ğ ı İ ö ş ü',
52
+ characters: /[çğıöşüÇĞİÖŞÜ]/g,
53
+ minPerThousandLetters: 0.1,
54
+ },
55
+ } as const;
56
+
57
+ const LETTERS = /\p{L}/gu;
58
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
59
+
60
+ function collectStrings(value: unknown): string[] {
61
+ if (typeof value === 'string') return [value];
62
+ if (!value || typeof value !== 'object') return [];
63
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
64
+ return Object.values(value).flatMap(collectStrings);
65
+ }
66
+
67
+ function normalizeText(value: unknown): string {
68
+ return collectStrings(value).join(' ').normalize('NFC');
69
+ }
70
+
71
+ function translatableContent(content: Record<string, unknown>) {
72
+ return TRANSLATABLE_KEYS.map((key) => content[key]);
73
+ }
74
+
75
+ function letterCount(text: string): number {
76
+ return text.match(LETTERS)?.length ?? 0;
77
+ }
78
+
79
+ function diacriticCount(text: string, locale: LocaleWithDiacritics): number {
80
+ return text.match(DIACRITIC_RULES[locale].characters)?.length ?? 0;
81
+ }
82
+
83
+ function diacriticsPerThousandLetters(text: string, locale: LocaleWithDiacritics): number {
84
+ const letters = letterCount(text);
85
+ if (letters === 0) return 0;
86
+ return diacriticCount(text, locale) / letters * 1000;
87
+ }
88
+
89
+ describe('Diacritics density validation', () => {
90
+ ALL_TOOLS.forEach((tool) => {
91
+ describe(`Tool: ${tool.entry.id}`, () => {
92
+ Object.keys(DIACRITIC_RULES).forEach((locale) => {
93
+ it(`${locale} keeps the expected accent and special-letter set`, async () => {
94
+ const typedLocale = locale as LocaleWithDiacritics;
95
+ const loader = tool.entry.i18n[typedLocale];
96
+ if (!loader) return;
97
+
98
+ const content = await loader();
99
+ const text = normalizeText(translatableContent(content as Record<string, unknown>));
100
+ const rule = DIACRITIC_RULES[typedLocale];
101
+ const letters = letterCount(text);
102
+ const matches = diacriticCount(text, typedLocale);
103
+ const density = diacriticsPerThousandLetters(text, typedLocale);
104
+
105
+ expect(
106
+ density,
107
+ [
108
+ `Possible spelling or encoding issue detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
109
+ `The text has ${matches} special characters (${density.toFixed(2)} per 1000 letters, ${letters} letters analyzed).`,
110
+ `This locale should include some of these characters: ${rule.expectedCharacters}.`,
111
+ 'If the count is 0 or near 0, accents, tildes, or special letters were probably stripped by encoding or normalization.',
112
+ ].join(' '),
113
+ ).toBeGreaterThanOrEqual(rule.minPerThousandLetters);
114
+ });
115
+ });
116
+ });
117
+ });
118
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const INVERTED_PUNCTUATION_LOCALES = {
5
+ es: {
6
+ language: 'Spanish',
7
+ questionStart: '¿',
8
+ questionEnd: '?',
9
+ exclamationStart: '¡',
10
+ exclamationEnd: '!',
11
+ },
12
+ } as const;
13
+
14
+ type InvertedPunctuationLocale = keyof typeof INVERTED_PUNCTUATION_LOCALES;
15
+
16
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
17
+
18
+ function collectStrings(value: unknown): string[] {
19
+ if (typeof value === 'string') return [value];
20
+ if (!value || typeof value !== 'object') return [];
21
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
22
+ return Object.values(value).flatMap(collectStrings);
23
+ }
24
+
25
+ function translatableStrings(content: Record<string, unknown>): string[] {
26
+ return TRANSLATABLE_KEYS.flatMap((key) => collectStrings(content[key]));
27
+ }
28
+
29
+ function sentenceStart(text: string, endIndex: number): string {
30
+ const beforeMark = text.slice(0, endIndex).trimEnd();
31
+ const boundary = Math.max(
32
+ beforeMark.lastIndexOf('.'),
33
+ beforeMark.lastIndexOf(':'),
34
+ beforeMark.lastIndexOf(';'),
35
+ beforeMark.lastIndexOf('\n'),
36
+ );
37
+
38
+ return beforeMark.slice(boundary + 1).trimStart();
39
+ }
40
+
41
+ function findMissingInvertedMarks(
42
+ text: string,
43
+ startMark: string,
44
+ endMark: string,
45
+ ): string[] {
46
+ return [...text.matchAll(new RegExp(`\\${endMark}`, 'g'))]
47
+ .map((match) => sentenceStart(text, match.index ?? 0))
48
+ .filter((segment) => segment.length > 0 && !segment.includes(startMark))
49
+ .map((segment) => `${segment}${endMark}`);
50
+ }
51
+
52
+ describe('Inverted punctuation validation', () => {
53
+ ALL_TOOLS.forEach((tool) => {
54
+ describe(`Tool: ${tool.entry.id}`, () => {
55
+ Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
56
+ it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
57
+ const typedLocale = locale as InvertedPunctuationLocale;
58
+ const loader = tool.entry.i18n[typedLocale];
59
+ if (!loader) return;
60
+
61
+ const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
62
+ const content = await loader();
63
+ const strings = translatableStrings(content as Record<string, unknown>);
64
+ const missingQuestions = strings.flatMap((text) =>
65
+ findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
66
+ );
67
+ const missingExclamations = strings.flatMap((text) =>
68
+ findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd)
69
+ );
70
+ const failures = [...missingQuestions, ...missingExclamations];
71
+
72
+ expect(
73
+ failures,
74
+ [
75
+ `Missing opening punctuation marks in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
76
+ `Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
77
+ `Examples: ${failures.slice(0, 5).join(' | ')}`,
78
+ ].join(' '),
79
+ ).toHaveLength(0);
80
+ });
81
+ });
82
+ });
83
+ });
84
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ function getFiles(dir: string): string[] {
6
+ const results: string[] = [];
7
+ if (!fs.existsSync(dir)) {
8
+ return results;
9
+ }
10
+ const list = fs.readdirSync(dir);
11
+ for (const file of list) {
12
+ const fullPath = path.join(dir, file);
13
+ const stat = fs.statSync(fullPath);
14
+ if (stat && stat.isDirectory()) {
15
+ if (file !== 'tests' && file !== 'node_modules' && file !== '.astro') {
16
+ results.push(...getFiles(fullPath));
17
+ }
18
+ } else {
19
+ results.push(fullPath);
20
+ }
21
+ }
22
+ return results;
23
+ }
24
+
25
+ function isContentFile(filePath: string): boolean {
26
+ return /\\i18n\\/.test(filePath) || filePath.endsWith('bibliography.ts');
27
+ }
28
+
29
+ const srcDir = path.join(process.cwd(), 'src');
30
+ const scriptsDir = path.join(process.cwd(), 'scripts');
31
+ const filesToTest = [
32
+ ...getFiles(srcDir).filter(isContentFile),
33
+ ...getFiles(scriptsDir).filter(isContentFile),
34
+ ];
35
+
36
+ const aiTypographyGarbage = [
37
+ '\u2013',
38
+ '\u2014',
39
+ '\u2026',
40
+ '\u201C',
41
+ '\u201D',
42
+ '\u2018',
43
+ '\u2019',
44
+ '\u00AB',
45
+ '\u00BB',
46
+ '\u200B',
47
+ '\u201E',
48
+ ];
49
+
50
+ describe('Typography Garbage Character Validation', () => {
51
+ filesToTest.forEach((filePath) => {
52
+ const relativePath = path.relative(process.cwd(), filePath);
53
+ it(`should not contain typography garbage characters in ${relativePath}`, () => {
54
+ const content = fs.readFileSync(filePath, 'utf-8');
55
+ const hasAiPatterns = aiTypographyGarbage.some(char => content.includes(char));
56
+ expect(hasAiPatterns).toBe(false);
57
+ });
58
+
59
+ it(`should not contain space before colon in ${relativePath}`, () => {
60
+ const content = fs.readFileSync(filePath, 'utf-8');
61
+ const spaceBeforeColon = / : /.test(content);
62
+ expect(spaceBeforeColon).toBe(false);
63
+ });
64
+
65
+ it(`should not contain double hyphen in ${relativePath}`, () => {
66
+ const content = fs.readFileSync(filePath, 'utf-8');
67
+ const cleaned = content.replace(/var\(--[\w-]+\)/g, '');
68
+ expect(cleaned).not.toContain('--');
69
+ });
70
+ });
71
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ type ScriptLocale = keyof typeof SCRIPT_RULES;
5
+
6
+ const SCRIPT_RULES = {
7
+ ja: {
8
+ language: 'Japanese',
9
+ scriptName: 'kana/kanji',
10
+ scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
11
+ minScriptRatio: 0.45,
12
+ },
13
+ ko: {
14
+ language: 'Korean',
15
+ scriptName: 'hangul',
16
+ scriptCharacters: /\p{Script=Hangul}/gu,
17
+ minScriptRatio: 0.55,
18
+ },
19
+ ru: {
20
+ language: 'Russian',
21
+ scriptName: 'cyrillic',
22
+ scriptCharacters: /\p{Script=Cyrillic}/gu,
23
+ minScriptRatio: 0.65,
24
+ },
25
+ zh: {
26
+ language: 'Chinese',
27
+ scriptName: 'han',
28
+ scriptCharacters: /\p{Script=Han}/gu,
29
+ minScriptRatio: 0.45,
30
+ },
31
+ } as const;
32
+
33
+ const LETTERS = /\p{L}/gu;
34
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
35
+
36
+ function collectStrings(value: unknown): string[] {
37
+ if (typeof value === 'string') return [value];
38
+ if (!value || typeof value !== 'object') return [];
39
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
40
+ return Object.values(value).flatMap(collectStrings);
41
+ }
42
+
43
+ function normalizeText(value: unknown): string {
44
+ return collectStrings(value).join(' ').normalize('NFC');
45
+ }
46
+
47
+ function translatableContent(content: Record<string, unknown>) {
48
+ return TRANSLATABLE_KEYS.map((key) => content[key]);
49
+ }
50
+
51
+ function letterCount(text: string): number {
52
+ return text.match(LETTERS)?.length ?? 0;
53
+ }
54
+
55
+ function scriptCount(text: string, locale: ScriptLocale): number {
56
+ return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
57
+ }
58
+
59
+ function scriptRatio(text: string, locale: ScriptLocale): number {
60
+ const letters = letterCount(text);
61
+ if (letters === 0) return 0;
62
+ return scriptCount(text, locale) / letters;
63
+ }
64
+
65
+ describe('Native script density validation', () => {
66
+ ALL_TOOLS.forEach((tool) => {
67
+ describe(`Tool: ${tool.entry.id}`, () => {
68
+ Object.keys(SCRIPT_RULES).forEach((locale) => {
69
+ it(`${locale} keeps most translated text in its native script`, async () => {
70
+ const typedLocale = locale as ScriptLocale;
71
+ const loader = tool.entry.i18n[typedLocale];
72
+ if (!loader) return;
73
+
74
+ const content = await loader();
75
+ const rule = SCRIPT_RULES[typedLocale];
76
+ const text = normalizeText(translatableContent(content as Record<string, unknown>));
77
+ const letters = letterCount(text);
78
+ const matches = scriptCount(text, typedLocale);
79
+ const ratio = scriptRatio(text, typedLocale);
80
+
81
+ expect(
82
+ ratio,
83
+ [
84
+ `Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
85
+ `The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
86
+ `Most translatable content should be written in ${rule.scriptName} script.`,
87
+ 'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
88
+ ].join(' '),
89
+ ).toBeGreaterThanOrEqual(rule.minScriptRatio);
90
+ });
91
+ });
92
+ });
93
+ });
94
+ });
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'grillen-thermometer';
7
- const title = 'Grillen Thermometer Dolbearsches Gesetz Temperaturrechner';
7
+ const title = 'Grillen Thermometer: Dolbearsches Gesetz Temperaturrechner';
8
8
  const description =
9
9
  'Kein Thermometer zur Hand? Hören Sie den Grillen zu. Berechnen Sie die exakte Temperatur, indem Sie das Zirpen mit unserem Dolbearsches Gesetz Rechner zählen.';
10
10
 
@@ -105,7 +105,7 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
105
105
  {
106
106
  type: 'tip',
107
107
  title: 'Warum singen Grillen?',
108
- html: '<p>Der Gesang der Grille, auch <strong>Stridulation</strong> genannt, ist eigentlich ein Paarungsruf. Die Männchen reiben ihre Flügel (nicht ihre Beine) aneinander, um diesen Laut zu erzeugen. Faszinierenderweise hängt die Geschwindigkeit dieses Reibens direkt von der Wärmeenergie der Luft ab, da Grillen wechselwarme Tiere (Ektothermen) sind.</p>',
108
+ html: '<p>Der "Gesang" der Grille, auch <strong>Stridulation</strong> genannt, ist eigentlich ein Paarungsruf. Die Männchen reiben ihre Flügel (nicht ihre Beine) aneinander, um diesen Laut zu erzeugen. Faszinierenderweise hängt die Geschwindigkeit dieses Reibens direkt von der Wärmeenergie der Luft ab, da Grillen wechselwarme Tiere (Ektothermen) sind.</p>',
109
109
  },
110
110
  {
111
111
  type: 'title',
@@ -146,9 +146,9 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
146
146
  {
147
147
  type: 'list',
148
148
  items: [
149
- '<strong>Thermometer der Liebe:</strong> Einige Theorien besagen, dass Weibchen Männchen bevorzugen, die in der korrekten Frequenz für die aktuelle Temperatur singen, da dies darauf hindeutet, dass das Männchen gesund ist und einen starken Stoffwechsel hat.',
149
+ '<strong>Thermometer der Liebe:</strong> Einige Theorien besagen, dass Weibchen Männchen bevorzugen, die in der "korrekten" Frequenz für die aktuelle Temperatur singen, da dies darauf hindeutet, dass das Männchen gesund ist und einen starken Stoffwechsel hat.',
150
150
  '<strong>Kältegrenze:</strong> Unterhalb von 10°C (50°F) hören die meisten Grillen auf zu singen, da ihr Stoffwechsel zu langsam ist, um die muskuläre Anstrengung aufrechtzuerhalten.',
151
- '<strong>Synchronisation:</strong> In warmen Nächten können tausende Grillen ihr Zirpen synchronisieren und so einen beeindruckenden Wellen“-Klangeffekt erzeugen.',
151
+ '<strong>Synchronisation:</strong> In warmen Nächten können tausende Grillen ihr Zirpen synchronisieren und so einen beeindruckenden "Wellen"-Klangeffekt erzeugen.',
152
152
  ],
153
153
  },
154
154
  {
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'cricket-thermometer';
7
- const title = 'Cricket Thermometer Dolbear\'s Law Temperature Calculator';
7
+ const title = 'Cricket Thermometer: Dolbear\'s Law Temperature Calculator';
8
8
  const description =
9
9
  'No thermometer? Listen to the crickets. Calculate the exact temperature by counting chirps with our Dolbear\'s Law calculator.';
10
10
 
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'termometro-grillo';
7
- const title = '¿Qué temperatura hace? Termómetro de Grillos (Ley de Dolbear)';
7
+ const title = '¿Qué temperatura hace?: Termómetro de Grillos (Ley de Dolbear)';
8
8
  const description =
9
9
  '¿No tienes termómetro? Escucha a los grillos. Calcula la temperatura exacta contando sus chirridos con nuestra calculadora de la Ley de Dolbear.';
10
10
 
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'thermometre-grillon';
7
- const title = 'Thermomètre à Grillons Loi de Dolbear';
7
+ const title = 'Thermomètre à Grillons: Loi de Dolbear';
8
8
  const description =
9
9
  'Pas de thermomètre ? Écoutez les grillons. Calculez la température exacte en comptant les stridulations avec notre calculateur de la Loi de Dolbear.';
10
10
 
@@ -95,7 +95,7 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
95
95
  seo: [
96
96
  {
97
97
  type: 'title',
98
- text: 'Guide Complet : Comment Utiliser la Loi de Dolbear pour Calculer la Température',
98
+ text: 'Guide Complet: Comment Utiliser la Loi de Dolbear pour Calculer la Température',
99
99
  level: 2,
100
100
  },
101
101
  {
@@ -109,12 +109,12 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
109
109
  },
110
110
  {
111
111
  type: 'title',
112
- text: 'La Science : Ectothermie et Métabolisme',
112
+ text: 'La Science: Ectothermie et Métabolisme',
113
113
  level: 3,
114
114
  },
115
115
  {
116
116
  type: 'paragraph',
117
- html: 'Contrairement aux mammifères, qui maintiennent une température corporelle constante, les insectes dépendent de la chaleur externe. Leurs réactions biochimiques suivent l\'<strong>Équation d\'Arrhenius</strong> : plus il fait chaud, plus la réaction est rapide.',
117
+ html: 'Contrairement aux mammifères, qui maintiennent une température corporelle constante, les insectes dépendent de la chaleur externe. Leurs réactions biochimiques suivent l\'<strong>Équation d\'Arrhenius</strong>: plus il fait chaud, plus la réaction est rapide.',
118
118
  },
119
119
  {
120
120
  type: 'paragraph',
@@ -136,7 +136,7 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
136
136
  },
137
137
  {
138
138
  type: 'paragraph',
139
- html: 'Notre outil fait cela automatiquement : il mesure le temps entre vos tapotements, calcule les stridulations par minute (BPM) et applique la formule instantanément.',
139
+ html: 'Notre outil fait cela automatiquement: il mesure le temps entre vos tapotements, calcule les stridulations par minute (BPM) et applique la formule instantanément.',
140
140
  },
141
141
  {
142
142
  type: 'title',
@@ -162,8 +162,8 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
162
162
  labelTapping: 'Continuez à tapoter...',
163
163
  tapInstruction: 'Chaque fois que vous entendez une stridulation',
164
164
  btnReset: 'Réinitialiser',
165
- btnSoundOn: 'Son : On',
166
- btnSoundOff: 'Son : Off',
165
+ btnSoundOn: 'Son: On',
166
+ btnSoundOff: 'Son: Off',
167
167
  unitChirpsMin: 'strid./min',
168
168
  },
169
169
  };
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'termometer-jangkrik';
7
- const title = 'Termometer Jangkrik Kalkulator Suhu Hukum Dolbear';
7
+ const title = 'Termometer Jangkrik: Kalkulator Suhu Hukum Dolbear';
8
8
  const description =
9
9
  'Tidak ada termometer? Dengarkan jangkrik. Hitung suhu tepat dengan menghitung kerikan menggunakan kalkulator Hukum Dolbear kami.';
10
10
 
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'termometro-a-grillo';
7
- const title = 'Termometro a Grillo Calcolatore della Temperatura via Legge di Dolbear';
7
+ const title = 'Termometro a Grillo: Calcolatore della Temperatura via Legge di Dolbear';
8
8
  const description =
9
9
  'Senza termometro? Ascolta i grilli. Calcola la temperatura esatta contando i friniti con il nostro calcolatore basato sulla Legge di Dolbear.';
10
10
 
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'cricket-thermometer';
7
- const title = 'コオロギ温度計 ドルベアの法則による温度計算機';
7
+ const title = 'コオロギ温度計: ドルベアの法則による温度計算機';
8
8
  const description =
9
9
  '温度計がない?そんな時はコオロギの声を聞きましょう。ドルベアの法則に基づき、鳴き声を数えるだけで正確な温度を算出します。';
10
10
 
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'cricket-thermometer';
7
- const title = '귀뚜라미 온도계 돌베어 법칙 온도 계산기';
7
+ const title = '귀뚜라미 온도계: 돌베어 법칙 온도 계산기';
8
8
  const description =
9
9
  '온도계가 없으신가요? 귀뚜라미 소리에 귀를 기울여 보세요. 돌베어 법칙 계산기를 사용하여 귀뚜라미 울음소리 횟수로 정확한 온도를 계산할 수 있습니다.';
10
10
 
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'krekels-thermometer';
7
- const title = 'Krekels thermometer Dolbears wet temperatuurcalculator';
7
+ const title = 'Krekels thermometer: Dolbears wet temperatuurcalculator';
8
8
  const description =
9
9
  'Geen thermometer? Luister naar de krekels. Bereken de exacte temperatuur door het aantal tsjirpen te tellen met onze Dolbears wet calculator.';
10
10
 
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'termometr-swierszczy';
7
- const title = 'Termometr świerszczowy Kalkulator temperatury według prawa Dolbeara';
7
+ const title = 'Termometr świerszczowy: Kalkulator temperatury według prawa Dolbeara';
8
8
  const description =
9
9
  'Nie masz termometru? Posłuchaj świerszczy. Oblicz dokładną temperaturę, licząc cykania za pomocą naszego kalkulatora opartego na prawie Dolbeara.';
10
10
 
@@ -104,8 +104,8 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
104
104
  },
105
105
  {
106
106
  type: 'tip',
107
- title: 'Dlaczego świerszcze „śpiewają”?',
108
- html: '<p>„Śpiew” świerszcza, czyli <strong>strydulacja</strong>, to w rzeczywistości wołanie godowe. Samce pocierają o siebie skrzydła (nie nogi), aby wydać ten dźwięk. Co ciekawe, ponieważ są to zwierzęta zmiennocieplne (ektotermy), szybkość tego pocierania zależy bezpośrednio od energii cieplnej powietrza.</p>',
107
+ title: 'Dlaczego świerszcze "śpiewają"?',
108
+ html: '<p>"Śpiew" świerszcza, czyli <strong>strydulacja</strong>, to w rzeczywistości wołanie godowe. Samce pocierają o siebie skrzydła (nie nogi), aby wydać ten dźwięk. Co ciekawe, ponieważ są to zwierzęta zmiennocieplne (ektotermy), szybkość tego pocierania zależy bezpośrednio od energii cieplnej powietrza.</p>',
109
109
  },
110
110
  {
111
111
  type: 'title',
@@ -146,7 +146,7 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
146
146
  {
147
147
  type: 'list',
148
148
  items: [
149
- '<strong>Termometry miłości:</strong> Niektóre teorie sugerują, że samice wolą samców śpiewających z „właściwą” częstotliwością dla aktualnej temperatury, ponieważ świadczy to o zdrowiu samca i silnym metabolizmie.',
149
+ '<strong>Termometry miłości:</strong> Niektóre teorie sugerują, że samice wolą samców śpiewających z "właściwą" częstotliwością dla aktualnej temperatury, ponieważ świadczy to o zdrowiu samca i silnym metabolizmie.',
150
150
  '<strong>Granica zimna:</strong> Poniżej 10°C (50°F) większość świerszczy przestaje śpiewać, ponieważ ich metabolizm jest zbyt wolny, by podtrzymać wysiłek mięśni.',
151
151
  '<strong>Synchronizacja:</strong> W ciepłe noce tysiące świerszczy potrafią zsynchronizować swoje cykanie, tworząc imponujący efekt fali dźwiękowej.',
152
152
  ],
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'termometro-grilo';
7
- const title = 'Termómetro de Grilo Calculadora de Temperatura da Lei de Dolbear';
7
+ const title = 'Termómetro de Grilo: Calculadora de Temperatura da Lei de Dolbear';
8
8
  const description =
9
9
  'Sem termómetro? Ouça os grilos. Calcule a temperatura exata contando os cri-cris com a nossa calculadora da Lei de Dolbear.';
10
10
 
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'sverchkovyy-termometr';
7
- const title = 'Сверчковый термометр Калькулятор температуры по закону Долбера';
7
+ const title = 'Сверчковый термометр: Калькулятор температуры по закону Долбера';
8
8
  const description =
9
9
  'Нет термометра? Послушайте сверчков. Рассчитайте точную температуру, подсчитав количество стрекотаний с помощью нашего калькулятора закона Долбера.';
10
10
 
@@ -17,7 +17,7 @@ const faqData = [
17
17
  {
18
18
  question: 'Почему в жару сверчки стрекочут быстрее?',
19
19
  answer:
20
- 'Сверчки холоднокровные животные (эктотермы). Скорость их метаболических процессов и мышечных сокращений зависит от внешней температуры; чем теплее, тем больше у них энергии для быстрого издания звуков.',
20
+ 'Сверчки - холоднокровные животные (эктотермы). Скорость их метаболических процессов и мышечных сокращений зависит от внешней температуры; чем теплее, тем больше у них энергии для быстрого издания звуков.',
21
21
  },
22
22
  {
23
23
  question: 'Насколько точны эти измерения?',
@@ -104,8 +104,8 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
104
104
  },
105
105
  {
106
106
  type: 'tip',
107
- title: 'Почему сверчки «поют»?',
108
- html: '<p>«Песня» сверчка, или <strong>стридуляция</strong>, на самом деле является брачным зовом. Самцы трут крыльями (не ногами) друг о друга, чтобы создать этот звук. Удивительно, но так как они являются холоднокровными животными (эктотермами), скорость этого трения напрямую зависит от тепловой энергии воздуха.</p>',
107
+ title: 'Почему сверчки "поют"?',
108
+ html: '<p>"Песня" сверчка, или <strong>стридуляция</strong>, на самом деле является брачным зовом. Самцы трут крыльями (не ногами) друг о друга, чтобы создать этот звук. Удивительно, но так как они являются холоднокровными животными (эктотермами), скорость этого трения напрямую зависит от тепловой энергии воздуха.</p>',
109
109
  },
110
110
  {
111
111
  type: 'title',
@@ -146,9 +146,9 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
146
146
  {
147
147
  type: 'list',
148
148
  items: [
149
- '<strong>Термометры любви:</strong> Некоторые теории предполагают, что самки предпочитают самцов, которые поют на «правильной» частоте для текущей температуры, так как это указывает на то, что самец здоров и имеет сильный метаболизм.',
149
+ '<strong>Термометры любви:</strong> Некоторые теории предполагают, что самки предпочитают самцов, которые поют на "правильной" частоте для текущей температуры, так как это указывает на то, что самец здоров и имеет сильный метаболизм.',
150
150
  '<strong>Предел холода:</strong> Ниже 10°C (50°F) большинство сверчков перестают петь, потому что их метаболизм слишком медленный для поддержания мышечного усилия.',
151
- '<strong>Синхронизация:</strong> Теплыми ночами тысячи сверчков могут синхронизировать свое стрекотание, создавая впечатляющий звуковой эффект «волны».',
151
+ '<strong>Синхронизация:</strong> Теплыми ночами тысячи сверчков могут синхронизировать свое стрекотание, создавая впечатляющий звуковой эффект "волны".',
152
152
  ],
153
153
  },
154
154
  {
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'syrstermometer';
7
- const title = 'Syrstermometer Dolbears lag temperaturkalkylator';
7
+ const title = 'Syrstermometer: Dolbears lag temperaturkalkylator';
8
8
  const description =
9
9
  'Ingen termometer? Lyssna på syrsorna. Beräkna exakt temperatur genom att räkna spelningar med vår kalkylator för Dolbears lag.';
10
10
 
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'cricket-termometer';
7
- const title = 'Cırcır Böceği Termometresi Dolbear Yasası Sıcaklık Hesaplayıcı';
7
+ const title = 'Cırcır Böceği Termometresi: Dolbear Yasası Sıcaklık Hesaplayıcı';
8
8
  const description =
9
9
  'Termometreniz mi yok? Cırcır böceklerini dinleyin. Dolbear Yasası hesaplayıcımızla ötüşleri sayarak tam sıcaklığı hesaplayın.';
10
10
 
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../../../types';
4
4
  import type { CricketThermometerUI } from '../ui';
5
5
 
6
6
  const slug = 'cricket-thermometer';
7
- const title = '蟋蟀温度计 杜倍耳定律温度计算器';
7
+ const title = '蟋蟀温度计: 杜倍耳定律温度计算器';
8
8
  const description =
9
9
  '没有温度计?听听蟋蟀的声音。使用我们的杜倍耳定律计算器,通过计算鸣叫次数来得出准确温度。';
10
10
 
@@ -42,7 +42,7 @@ const howToData = [
42
42
  },
43
43
  {
44
44
  name: '输入数值',
45
- text: '跟随鸣叫节奏点击“TAP”按钮几秒钟,计算器会自动计算 BPM(每分钟鸣叫次数)。',
45
+ text: '跟随鸣叫节奏点击"TAP"按钮几秒钟,计算器会自动计算 BPM(每分钟鸣叫次数)。',
46
46
  },
47
47
  {
48
48
  name: '验证温度',
@@ -104,8 +104,8 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
104
104
  },
105
105
  {
106
106
  type: 'tip',
107
- title: '蟋蟀为什么要“唱歌”?',
108
- html: '<p>蟋蟀的“歌声”,即<strong>摩擦发声</strong>,实际上是一种求偶信号。公蟋蟀通过摩擦双翅(而不是腿)来发出这种声音。令人着迷的是,由于它们是冷血动物(变温动物),这种摩擦的速度直接取决于空气的热能。</p>',
107
+ title: '蟋蟀为什么要"唱歌"?',
108
+ html: '<p>蟋蟀的"歌声",即<strong>摩擦发声</strong>,实际上是一种求偶信号。公蟋蟀通过摩擦双翅(而不是腿)来发出这种声音。令人着迷的是,由于它们是冷血动物(变温动物),这种摩擦的速度直接取决于空气的热能。</p>',
109
109
  },
110
110
  {
111
111
  type: 'title',
@@ -146,9 +146,9 @@ export const content: ToolLocaleContent<CricketThermometerUI> = {
146
146
  {
147
147
  type: 'list',
148
148
  items: [
149
- '<strong>爱情温度计:</strong> 一些理论认为,母蟋蟀更喜欢在当前温度下以“正确”频率唱歌的公蟋蟀,因为这表明该公蟋蟀身体健康且代谢强健。',
149
+ '<strong>爱情温度计:</strong> 一些理论认为,母蟋蟀更喜欢在当前温度下以"正确"频率唱歌的公蟋蟀,因为这表明该公蟋蟀身体健康且代谢强健。',
150
150
  '<strong>寒冷极限:</strong> 低于 10°C (50°F) 时,大多数蟋蟀会停止唱歌,因为它们的代谢太慢,无法维持肌肉输出。',
151
- '<strong>同步鸣叫:</strong> 在温暖的夜晚,成千上万只蟋蟀可以同步鸣叫,产生令人印象深刻的“声波”效果。',
151
+ '<strong>同步鸣叫:</strong> 在温暖的夜晚,成千上万只蟋蟀可以同步鸣叫,产生令人印象深刻的"声波"效果。',
152
152
  ],
153
153
  },
154
154
  {
@@ -95,6 +95,7 @@
95
95
 
96
96
  .dc-input {
97
97
  flex: 1;
98
+ min-width: 0;
98
99
  background: transparent;
99
100
  border: none;
100
101
  padding: 0.75rem 1rem;
@@ -339,4 +340,59 @@
339
340
  opacity: 0.05;
340
341
  color: var(--dc-accent);
341
342
  pointer-events: none;
342
- }
343
+ }
344
+
345
+ @media (max-width: 640px) {
346
+ .dc-wrap {
347
+ padding: 0.75rem;
348
+ }
349
+
350
+ .dc-container {
351
+ border-radius: 1.25rem;
352
+ }
353
+
354
+ .dc-inputs,
355
+ .dc-results-grid,
356
+ .dc-sidebar {
357
+ padding: 1.25rem;
358
+ }
359
+
360
+ .dc-section-head {
361
+ align-items: flex-start;
362
+ font-size: 1.25rem;
363
+ margin-bottom: 1.5rem;
364
+ }
365
+
366
+ .dc-search-box {
367
+ flex-direction: column;
368
+ gap: 0.75rem;
369
+ }
370
+
371
+ .dc-input {
372
+ width: 100%;
373
+ font-size: 1rem;
374
+ }
375
+
376
+ .dc-btn-primary {
377
+ width: 100%;
378
+ min-width: 0;
379
+ min-height: 3rem;
380
+ padding: 0.75rem 1rem;
381
+ }
382
+
383
+ .dc-result-card.main,
384
+ .dc-impact-items,
385
+ .dc-stats-grid {
386
+ grid-template-columns: 1fr;
387
+ }
388
+
389
+ .dc-result-card.main {
390
+ align-items: flex-start;
391
+ gap: 1rem;
392
+ padding: 1.25rem;
393
+ }
394
+
395
+ .dc-stat-item.full {
396
+ grid-column: auto;
397
+ }
398
+ }
@@ -143,7 +143,7 @@ export const content: DigitalCarbonLocaleContent = {
143
143
  },
144
144
  {
145
145
  type: 'paragraph',
146
- html: 'Der <strong>digitale CO₂-Fußabdruck</strong> einer Website wird in Gramm CO₂-Äquivalent (gCO₂e) pro Besuch gemessen. Eine durchschnittliche Website erzeugt etwa 0,5 g CO₂ pro Ladevorgang. Obwohl das unbedeutend scheint, kann eine Seite mit 100.000 monatlichen Besuchen mehr als 600 kg CO₂ pro Jahr emittieren was einer Fahrt von mehr als 3.000 km mit einem Benziner entspricht.',
146
+ html: 'Der <strong>digitale CO₂-Fußabdruck</strong> einer Website wird in Gramm CO₂-Äquivalent (gCO₂e) pro Besuch gemessen. Eine durchschnittliche Website erzeugt etwa 0,5 g CO₂ pro Ladevorgang. Obwohl das unbedeutend scheint, kann eine Seite mit 100.000 monatlichen Besuchen mehr als 600 kg CO₂ pro Jahr emittieren - was einer Fahrt von mehr als 3.000 km mit einem Benziner entspricht.',
147
147
  },
148
148
  {
149
149
  type: 'title',
@@ -221,7 +221,7 @@ export const content: DigitalCarbonLocaleContent = {
221
221
  },
222
222
  {
223
223
  type: 'paragraph',
224
- html: 'Das Internet macht zwischen <strong>2 % und 4 % der weltweiten CO₂-Emissionen</strong> aus ein Wert, der mit der Luftfahrtindustrie vergleichbar ist. Jedes Kilobyte, das Sie einsparen, macht nicht nur Ihre Website schneller: Es reduziert messbar die digitale Verschmutzung.',
224
+ html: 'Das Internet macht zwischen <strong>2 % und 4 % der weltweiten CO₂-Emissionen</strong> aus - ein Wert, der mit der Luftfahrtindustrie vergleichbar ist. Jedes Kilobyte, das Sie einsparen, macht nicht nur Ihre Website schneller: Es reduziert messbar die digitale Verschmutzung.',
225
225
  },
226
226
  ],
227
227
  bibliography,
@@ -21,7 +21,7 @@ const faqData = [
21
21
  },
22
22
  {
23
23
  question: 'Comment réduire le CO₂ de mon site web ?',
24
- answer: 'Le moyen le plus efficace est de réduire le poids de la page : optimisez les images (WebP), minimisez les fichiers CSS y JS, utilisez le chargement différé (lazy loading) y choisissez un hébergeur utilisant des énergies renouvelables.',
24
+ answer: 'Le moyen le plus efficace est de réduire le poids de la page: optimisez les images (WebP), minimisez les fichiers CSS y JS, utilisez le chargement différé (lazy loading) y choisissez un hébergeur utilisant des énergies renouvelables.',
25
25
  },
26
26
  ];
27
27
 
@@ -125,7 +125,7 @@ export const content: DigitalCarbonLocaleContent = {
125
125
  seo: [
126
126
  {
127
127
  type: 'title',
128
- text: 'Calculateur d\'empreinte carbone numérique : Quel est le CO₂ généré par votre site',
128
+ text: 'Calculateur d\'empreinte carbone numérique: Quel est le CO₂ généré par votre site',
129
129
  level: 2,
130
130
  },
131
131
  {
@@ -139,7 +139,7 @@ export const content: DigitalCarbonLocaleContent = {
139
139
  },
140
140
  {
141
141
  type: 'paragraph',
142
- html: 'Chaque fois que vous ouvrez une page web, votre appareil, votre routeur, les câbles sous-marins et les serveurs à l\'autre bout du monde consomment de l\'électricité. Cette électricité est encore largement générée par la combustion d\'énergies fossiles. Résultat : une quantité réelle de <strong>CO₂ émise dans l\'atmosphère à chaque visite</strong>.',
142
+ html: 'Chaque fois que vous ouvrez une page web, votre appareil, votre routeur, les câbles sous-marins et les serveurs à l\'autre bout du monde consomment de l\'électricité. Cette électricité est encore largement générée par la combustion d\'énergies fossiles. Résultat: une quantité réelle de <strong>CO₂ émise dans l\'atmosphère à chaque visite</strong>.',
143
143
  },
144
144
  {
145
145
  type: 'paragraph',
@@ -221,7 +221,7 @@ export const content: DigitalCarbonLocaleContent = {
221
221
  },
222
222
  {
223
223
  type: 'paragraph',
224
- html: 'Internet représente entre <strong>2% et 4% des émissions mondiales de CO₂</strong>, un chiffre comparable à l\'industrie aéronautique. Chaque kilo-octet éliminé ne rend pas seulement votre site plus rapide : il réduit de manière mesurable la pollution numérique.',
224
+ html: 'Internet représente entre <strong>2% et 4% des émissions mondiales de CO₂</strong>, un chiffre comparable à l\'industrie aéronautique. Chaque kilo-octet éliminé ne rend pas seulement votre site plus rapide: il réduit de manière mesurable la pollution numérique.',
225
225
  },
226
226
  ],
227
227
  bibliography,
@@ -21,7 +21,7 @@ const faqData = [
21
21
  },
22
22
  {
23
23
  question: 'Как я могу уменьшить выбросы CO₂ моего сайта?',
24
- answer: 'Самый эффективный способ уменьшить вес страницы: оптимизировать изображения (WebP), минифицировать файлы CSS и JS, использовать ленивую загрузку и выбрать хостинг-провайдера, использующего возобновляемую энергию.',
24
+ answer: 'Самый эффективный способ - уменьшить вес страницы: оптимизировать изображения (WebP), минифицировать файлы CSS и JS, использовать ленивую загрузку и выбрать хостинг-провайдера, использующего возобновляемую энергию.',
25
25
  },
26
26
  ];
27
27
 
@@ -9,7 +9,7 @@ const description = 'Berechnen Sie, wie viel Regenwasser Sie von Ihrem Dach samm
9
9
  const faqData = [
10
10
  {
11
11
  question: 'Wie viel Wasser kann ich tatsächlich von meinem Dach sammeln?',
12
- answer: 'Als Faustregel gilt: Pro Quadratmeter Dachfläche und Millimeter Regen können Sie etwa 1 Liter Wasser sammeln. Es entstehen jedoch Verluste durch Verdunstung und Filtration, die mit dem Abflussbeiwert angepasst werden.',
12
+ answer: 'Als Faustregel gilt: Pro Quadratmeter Dachfläche und Millimeter Regen können Sie etwa 1 Liter Wasser sammeln. Es entstehen jedoch Verluste durch Verdunstung und Filtration, die mit dem "Abflussbeiwert" angepasst werden.',
13
13
  },
14
14
  {
15
15
  question: 'Was ist der Abflussbeiwert?',
@@ -91,7 +91,7 @@ export const content: RainHarvesterLocaleContent = {
91
91
  unitM2: 'm²',
92
92
  unitMm: 'mm',
93
93
  unitLiters: 'Liter',
94
- helpRainfall: 'Unbekannt? Suchen Sie bei Google nach durchschnittlicher jährlicher Niederschlag [Ihre Stadt]“.',
94
+ helpRainfall: 'Unbekannt? Suchen Sie bei Google nach "durchschnittlicher jährlicher Niederschlag [Ihre Stadt]".',
95
95
  efficiencyTitle: 'Effizienzfaktor',
96
96
  efficiencyNote: 'Für Filter und Verdunstung wird ein Verlust von 10 % angesetzt.',
97
97
  resultTitle: 'Jährliches Erntepotenzial',
@@ -113,7 +113,7 @@ export const content: RainHarvesterLocaleContent = {
113
113
  },
114
114
  {
115
115
  type: 'paragraph',
116
- html: 'Die meisten Hausbesitzer sind sich des Potenzials ihres eigenen Daches nicht bewusst. Ein Standarddach kann jedes Jahr Tausende Liter kostenloses Wasser auffangen. Dieses Tool quantifiziert dieses Potenzial, sodass Sie genau berechnen können, wie viel Wasser Sie ernten können, und die ideale Tankgröße für die Speicherung bestimmen können.',
116
+ html: 'Die meisten Hausbesitzer sind sich des Potenzials ihres eigenen Daches nicht bewusst. Ein Standarddach kann jedes Jahr Tausende Liter kostenloses Wasser auffangen. Dieses Tool quantifiziert dieses Potenzial, sodass Sie genau berechnen können, wie viel Wasser Sie "ernten" können, und die ideale Tankgröße für die Speicherung bestimmen können.',
117
117
  },
118
118
  {
119
119
  type: 'title',
@@ -108,7 +108,7 @@ export const content: RainHarvesterLocaleContent = {
108
108
  seo: [
109
109
  {
110
110
  type: 'title',
111
- text: 'Récupération de l\'eau de pluie : Autonomie et durabilité',
111
+ text: 'Récupération de l\'eau de pluie: Autonomie et durabilité',
112
112
  level: 2,
113
113
  },
114
114
  {
@@ -9,7 +9,7 @@ const description = 'Oblicz, ile wody deszczowej możesz zebrać ze swojego dach
9
9
  const faqData = [
10
10
  {
11
11
  question: 'Ile wody mogę faktycznie zebrać ze swojego dachu?',
12
- answer: 'Ogólna zasada mówi, że z każdego metra kwadratowego dachu i na każdy milimetr deszczu można zebrać około 1 litra wody. Istnieją jednak straty wynikające z parowania i filtracji, korygowane przez współczynnik spływu”.',
12
+ answer: 'Ogólna zasada mówi, że z każdego metra kwadratowego dachu i na każdy milimetr deszczu można zebrać około 1 litra wody. Istnieją jednak straty wynikające z parowania i filtracji, korygowane przez "współczynnik spływu".',
13
13
  },
14
14
  {
15
15
  question: 'Co to jest współczynnik spływu?',
@@ -91,7 +91,7 @@ export const content: RainHarvesterLocaleContent = {
91
91
  unitM2: 'm²',
92
92
  unitMm: 'mm',
93
93
  unitLiters: 'Litry',
94
- helpRainfall: 'Nie wiesz? Wyszukaj w Google „średnie roczne opady [Twoje miasto]”.',
94
+ helpRainfall: 'Nie wiesz? Wyszukaj w Google "średnie roczne opady [Twoje miasto]".',
95
95
  efficiencyTitle: 'Współczynnik Wydajności',
96
96
  efficiencyNote: 'Uwzględniono 10% strat na filtry i parowanie.',
97
97
  resultTitle: 'Roczny Potencjał Uzysku',
@@ -113,7 +113,7 @@ export const content: RainHarvesterLocaleContent = {
113
113
  },
114
114
  {
115
115
  type: 'paragraph',
116
- html: 'Większość właścicieli domów nie zdaje sobie sprawy z potencjału własnego dachu. Standardowy dach może zebrać tysiące litrów darmowej wody każdego roku. To narzędzie pozwala oszacować ten potencjał, obliczyć dokładnie, ile wody możesz „zebrać” i dobrać idealny zbiornik do jej przechowywania.',
116
+ html: 'Większość właścicieli domów nie zdaje sobie sprawy z potencjału własnego dachu. Standardowy dach może zebrać tysiące litrów darmowej wody każdego roku. To narzędzie pozwala oszacować ten potencjał, obliczyć dokładnie, ile wody możesz "zebrać" i dobrać idealny zbiornik do jej przechowywania.',
117
117
  },
118
118
  {
119
119
  type: 'title',
@@ -9,11 +9,11 @@ const description = 'Рассчитайте, сколько дождевой в
9
9
  const faqData = [
10
10
  {
11
11
  question: 'Сколько воды я на самом деле могу собрать со своей крыши?',
12
- answer: 'Общее правило гласит, что на каждый квадратный метр крыши и каждый миллиметр дождя можно собрать примерно 1 литр воды. Однако существуют потери на испарение и фильтрацию, корректируемые «коэффициентом стока».',
12
+ answer: 'Общее правило гласит, что на каждый квадратный метр крыши и каждый миллиметр дождя можно собрать примерно 1 литр воды. Однако существуют потери на испарение и фильтрацию, корректируемые "коэффициентом стока".',
13
13
  },
14
14
  {
15
15
  question: 'Что такое коэффициент стока?',
16
- answer: 'Это процент, указывающий, сколько воды теряется в зависимости от материала крыши. Металлическая или черепичная крыша очень эффективна (0,850,95), в то время как гравийная или земляная крыша гораздо менее эффективна (0,10,3).',
16
+ answer: 'Это процент, указывающий, сколько воды теряется в зависимости от материала крыши. Металлическая или черепичная крыша очень эффективна (0,85-0,95), в то время как гравийная или земляная крыша гораздо менее эффективна (0,1-0,3).',
17
17
  },
18
18
  {
19
19
  question: 'Как подобрать размер накопительного бака?',
@@ -40,7 +40,7 @@ const howToData = [
40
40
  },
41
41
  {
42
42
  name: 'Рассчитайте необходимый объем',
43
- text: 'Используйте результат годового сбора в литрах, чтобы выбрать бак, который может хранить не менее 2030% от общего урожая для засушливых периодов.',
43
+ text: 'Используйте результат годового сбора в литрах, чтобы выбрать бак, который может хранить не менее 20-30% от общего урожая для засушливых периодов.',
44
44
  },
45
45
  ];
46
46
 
@@ -91,7 +91,7 @@ export const content: RainHarvesterLocaleContent = {
91
91
  unitM2: 'м²',
92
92
  unitMm: 'мм',
93
93
  unitLiters: 'Литры',
94
- helpRainfall: 'Не знаете? Поищите в Google «среднегодовое количество осадков в [вашем городе]».',
94
+ helpRainfall: 'Не знаете? Поищите в Google "среднегодовое количество осадков в [вашем городе]".',
95
95
  efficiencyTitle: 'Коэффициент эффективности',
96
96
  efficiencyNote: 'Применяется потеря 10% на фильтры и испарение.',
97
97
  resultTitle: 'Годовой потенциал сбора',
@@ -113,7 +113,7 @@ export const content: RainHarvesterLocaleContent = {
113
113
  },
114
114
  {
115
115
  type: 'paragraph',
116
- html: 'Большинство домовладельцев не подозревают о потенциале собственных крыш. Стандартная крыша может собирать тысячи литров бесплатной воды каждый год. Этот инструмент количественно определяет этот потенциал, позволяя вам точно рассчитать, сколько воды вы можете «собрать», и определить идеальный размер бака для хранения.',
116
+ html: 'Большинство домовладельцев не подозревают о потенциале собственных крыш. Стандартная крыша может собирать тысячи литров бесплатной воды каждый год. Этот инструмент количественно определяет этот потенциал, позволяя вам точно рассчитать, сколько воды вы можете "собрать", и определить идеальный размер бака для хранения.',
117
117
  },
118
118
  {
119
119
  type: 'title',
@@ -157,7 +157,7 @@ export const content: RainHarvesterLocaleContent = {
157
157
  },
158
158
  {
159
159
  type: 'paragraph',
160
- html: 'Самая распространенная ошибка покупка бака, исходя только из бюджета. Если он слишком мал, вы будете терять тысячи литров из-за перелива. Если он слишком велик, вы потратите деньги зря. Общее правило иметь емкость для хранения среднего количества осадков за <strong>3 месяца</strong> в вашем районе, что позволит вам пережить засушливые сезоны.',
160
+ html: 'Самая распространенная ошибка - покупка бака, исходя только из бюджета. Если он слишком мал, вы будете терять тысячи литров из-за перелива. Если он слишком велик, вы потратите деньги зря. Общее правило - иметь емкость для хранения среднего количества осадков за <strong>3 месяца</strong> в вашем районе, что позволит вам пережить засушливые сезоны.',
161
161
  },
162
162
  ],
163
163
  faq: faqData,
@@ -9,7 +9,7 @@ const description = '计算您可以从屋顶收集多少雨水,并确定储
9
9
  const faqData = [
10
10
  {
11
11
  question: '我实际上能从屋顶收集多少水?',
12
- answer: '一般规则是,屋顶每平方米面积在每毫米降雨下大约可以收集 1 升水。但由于蒸发和过滤的影响,需要通过“径流系数”进行调整。',
12
+ answer: '一般规则是,屋顶每平方米面积在每毫米降雨下大约可以收集 1 升水。但由于蒸发和过滤的影响,需要通过"径流系数"进行调整。',
13
13
  },
14
14
  {
15
15
  question: '什么是径流系数?',
@@ -91,7 +91,7 @@ export const content: RainHarvesterLocaleContent = {
91
91
  unitM2: 'm²',
92
92
  unitMm: 'mm',
93
93
  unitLiters: '升',
94
- helpRainfall: '不知道?在 Google 上搜索“(您所在的城市)年平均降水量”。',
94
+ helpRainfall: '不知道?在 Google 上搜索"(您所在的城市)年平均降水量"。',
95
95
  efficiencyTitle: '效率系数',
96
96
  efficiencyNote: '考虑到过滤器和蒸发,已应用 10% 的损失率。',
97
97
  resultTitle: '年收集潜力',
@@ -113,7 +113,7 @@ export const content: RainHarvesterLocaleContent = {
113
113
  },
114
114
  {
115
115
  type: 'paragraph',
116
- html: '大多数房主并未意识到自家屋顶的潜力。一个标准屋顶每年可以捕获数千升免费水。此工具将这种潜力具象化,让您能够精确计算可以“收获”多少水,并确定最理想的储水箱尺寸。',
116
+ html: '大多数房主并未意识到自家屋顶的潜力。一个标准屋顶每年可以捕获数千升免费水。此工具将这种潜力具象化,让您能够精确计算可以"收获"多少水,并确定最理想的储水箱尺寸。',
117
117
  },
118
118
  {
119
119
  type: 'title',
@@ -100,7 +100,7 @@ export const content: ToolLocaleContent<SeedCalculatorUI> = {
100
100
  },
101
101
  {
102
102
  type: 'paragraph',
103
- html: "Régler la transmission d'un semoir nécessite de savoir exactement combien de graines doivent tomber par mètre linéaire de sillon. Les manuels des machines donnent souvent des tables approximatives, mais des facteurs comme le <strong>patinage de la roue motrice</strong> ou la taille de la semence peuvent modifier la réalité. Cet outil vous donne la valeur théorique parfaite : l'<strong>espacement cible entre graines</strong>.",
103
+ html: "Régler la transmission d'un semoir nécessite de savoir exactement combien de graines doivent tomber par mètre linéaire de sillon. Les manuels des machines donnent souvent des tables approximatives, mais des facteurs comme le <strong>patinage de la roue motrice</strong> ou la taille de la semence peuvent modifier la réalité. Cet outil vous donne la valeur théorique parfaite: l'<strong>espacement cible entre graines</strong>.",
104
104
  },
105
105
  {
106
106
  type: 'tip',
@@ -105,7 +105,7 @@ export const content: ToolLocaleContent<SeedCalculatorUI> = {
105
105
  {
106
106
  type: 'tip',
107
107
  title: 'Wzór matematyczny',
108
- html: '<p>Dla agronomów i ciekawskich oto podstawa obliczeń:</p><pre>Odstęp (cm) = 10 000 000 / (Obsada × Szerokość Rzędu)</pre><ul><li><strong>10 000 000:</strong> Współczynnik konwersji z Ha na cm².</li><li><strong>Obsada:</strong> Liczba nasion na hektar.</li><li><strong>Szerokość Rzędu:</strong> Odległość między rzędami w cm.</li></ul>',
108
+ html: '<p>Dla agronomów i ciekawskich - oto podstawa obliczeń:</p><pre>Odstęp (cm) = 10 000 000 / (Obsada × Szerokość Rzędu)</pre><ul><li><strong>10 000 000:</strong> Współczynnik konwersji z Ha na cm².</li><li><strong>Obsada:</strong> Liczba nasion na hektar.</li><li><strong>Szerokość Rzędu:</strong> Odległość między rzędami w cm.</li></ul>',
109
109
  },
110
110
  {
111
111
  type: 'title',
@@ -27,7 +27,7 @@ const faqData = [
27
27
  {
28
28
  question: 'Почему так важна калибровка сеялки?',
29
29
  answer:
30
- 'Слишком густой посев вызывает конкуренцию между растениями и приводит к измельчанию зерна. Слишком редкий посев это потеря площади и потенциала урожайности. Точность ключ к прибыльности.',
30
+ 'Слишком густой посев вызывает конкуренцию между растениями и приводит к измельчанию зерна. Слишком редкий посев - это потеря площади и потенциала урожайности. Точность - ключ к прибыльности.',
31
31
  },
32
32
  ];
33
33
 
@@ -46,7 +46,7 @@ const howToData = [
46
46
  },
47
47
  {
48
48
  name: 'Получите настройки механизмов',
49
- text: 'Используйте значение «семян на метр» или «расстояние в см между семенами» для настройки звездочек или монитора на вашей машине.',
49
+ text: 'Используйте значение "семян на метр" или "расстояние в см между семенами" для настройки звездочек или монитора на вашей машине.',
50
50
  },
51
51
  ];
52
52
 
@@ -105,7 +105,7 @@ export const content: ToolLocaleContent<SeedCalculatorUI> = {
105
105
  {
106
106
  type: 'tip',
107
107
  title: 'Математическая формула',
108
- html: '<p>Для агрономов и любознательных вот основа расчета:</p><pre>Расстояние (см) = 10 000 000 / (Норма высева × Ширина междурядья)</pre><ul><li><strong>10 000 000:</strong> Коэффициент перевода из Га в см².</li><li><strong>Норма высева:</strong> Семян на гектар.</li><li><strong>Ширина междурядья:</strong> Расстояние между рядами в см.</li></ul>',
108
+ html: '<p>Для агрономов и любознательных - вот основа расчета:</p><pre>Расстояние (см) = 10 000 000 / (Норма высева × Ширина междурядья)</pre><ul><li><strong>10 000 000:</strong> Коэффициент перевода из Га в см².</li><li><strong>Норма высева:</strong> Семян на гектар.</li><li><strong>Ширина междурядья:</strong> Расстояние между рядами в см.</li></ul>',
109
109
  },
110
110
  {
111
111
  type: 'title',
@@ -136,7 +136,7 @@ export const content: ToolLocaleContent<SeedCalculatorUI> = {
136
136
  },
137
137
  {
138
138
  type: 'paragraph',
139
- html: '<strong>Метод 1/1000 гектара:</strong> Отмерьте расстояние вдоль борозды, соответствующее 1/1000 гектара. Подсчитайте семена и умножьте на 1000. Для междурядья 70 см расстояние составляет 14,28 м; для 52,5 см 19,05 м.',
139
+ html: '<strong>Метод 1/1000 гектара:</strong> Отмерьте расстояние вдоль борозды, соответствующее 1/1000 гектара. Подсчитайте семена и умножьте на 1000. Для междурядья 70 см расстояние составляет 14,28 м; для 52,5 см - 19,05 м.',
140
140
  },
141
141
  {
142
142
  type: 'paragraph',