@jjlmoya/utils-drones 1.17.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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/fr.ts +9 -9
  3. package/src/category/i18n/ru.ts +5 -5
  4. package/src/tests/diacritics_density.test.ts +118 -0
  5. package/src/tests/inverted_punctuation.test.ts +84 -0
  6. package/src/tests/no_en_dash.test.ts +70 -0
  7. package/src/tests/script_density.test.ts +94 -0
  8. package/src/tool/antenna-length-calculator/i18n/de.ts +2 -2
  9. package/src/tool/antenna-length-calculator/i18n/fr.ts +12 -12
  10. package/src/tool/antenna-length-calculator/i18n/ru.ts +6 -6
  11. package/src/tool/antenna-length-calculator/i18n/tr.ts +3 -3
  12. package/src/tool/antenna-length-calculator/i18n/zh.ts +10 -10
  13. package/src/tool/drone-flight-time/drone-flight-time-calculator.css +72 -7
  14. package/src/tool/drone-flight-time/i18n/fr.ts +11 -11
  15. package/src/tool/drone-flight-time/i18n/ru.ts +9 -9
  16. package/src/tool/drone-flight-time/i18n/sv.ts +3 -3
  17. package/src/tool/drone-flight-time/i18n/zh.ts +2 -2
  18. package/src/tool/drone-power-analyzer/i18n/de.ts +9 -9
  19. package/src/tool/drone-power-analyzer/i18n/en.ts +9 -9
  20. package/src/tool/drone-power-analyzer/i18n/es.ts +5 -5
  21. package/src/tool/drone-power-analyzer/i18n/fr.ts +15 -15
  22. package/src/tool/drone-power-analyzer/i18n/id.ts +9 -9
  23. package/src/tool/drone-power-analyzer/i18n/it.ts +6 -6
  24. package/src/tool/drone-power-analyzer/i18n/ja.ts +4 -4
  25. package/src/tool/drone-power-analyzer/i18n/ko.ts +4 -4
  26. package/src/tool/drone-power-analyzer/i18n/nl.ts +9 -9
  27. package/src/tool/drone-power-analyzer/i18n/pl.ts +11 -11
  28. package/src/tool/drone-power-analyzer/i18n/pt.ts +9 -9
  29. package/src/tool/drone-power-analyzer/i18n/ru.ts +17 -17
  30. package/src/tool/drone-power-analyzer/i18n/sv.ts +9 -9
  31. package/src/tool/drone-power-analyzer/i18n/tr.ts +9 -9
  32. package/src/tool/drone-power-analyzer/i18n/zh.ts +13 -13
  33. package/src/tool/gps-coordinates-converter/i18n/fr.ts +10 -10
  34. package/src/tool/gps-coordinates-converter/i18n/it.ts +2 -2
  35. package/src/tool/gps-coordinates-converter/i18n/pt.ts +4 -4
  36. package/src/tool/gps-coordinates-converter/i18n/ru.ts +7 -7
  37. package/src/tool/gps-coordinates-converter/i18n/zh.ts +4 -4
  38. package/src/tool/gsd-flight-planner/i18n/de.ts +2 -2
  39. package/src/tool/gsd-flight-planner/i18n/fr.ts +11 -11
  40. package/src/tool/gsd-flight-planner/i18n/ru.ts +5 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-drones",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,4 +1,4 @@
1
- import type { CategoryLocaleContent } from '../../types';
1
+ import type { CategoryLocaleContent } from '../../types';
2
2
 
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
@@ -7,7 +7,7 @@ export const content: CategoryLocaleContent = {
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
10
- text: 'Ingénierie RF et Vol de Précision : Outils pour Pilotes et Radio-opérateurs',
10
+ text: 'Ingénierie RF et Vol de Précision: Outils pour Pilotes et Radio-opérateurs',
11
11
  level: 2,
12
12
  },
13
13
  {
@@ -20,7 +20,7 @@ export const content: CategoryLocaleContent = {
20
20
  },
21
21
  {
22
22
  type: 'title',
23
- text: 'Planification de Vol : Temps de Vol et Autonomie de Batterie (mAh / Amps)',
23
+ text: 'Planification de Vol: Temps de Vol et Autonomie de Batterie (mAh / Amps)',
24
24
  level: 2,
25
25
  },
26
26
  {
@@ -29,7 +29,7 @@ export const content: CategoryLocaleContent = {
29
29
  },
30
30
  {
31
31
  type: 'title',
32
- text: 'Ingénierie d\'Antenne : Longueur d\'Onde et Fréquence Radio',
32
+ text: 'Ingénierie d\'Antenne: Longueur d\'Onde et Fréquence Radio',
33
33
  level: 2,
34
34
  },
35
35
  {
@@ -38,7 +38,7 @@ export const content: CategoryLocaleContent = {
38
38
  },
39
39
  {
40
40
  type: 'title',
41
- text: 'Navigation de Précision : Convertisseur de Coordonnées GPS',
41
+ text: 'Navigation de Précision: Convertisseur de Coordonnées GPS',
42
42
  level: 2,
43
43
  },
44
44
  {
@@ -69,7 +69,7 @@ export const content: CategoryLocaleContent = {
69
69
  },
70
70
  {
71
71
  type: 'title',
72
- text: 'Applications Commerciales : Cartographie Photogrammétrique et Inspection',
72
+ text: 'Applications Commerciales: Cartographie Photogrammétrique et Inspection',
73
73
  level: 2,
74
74
  },
75
75
  {
@@ -78,11 +78,11 @@ export const content: CategoryLocaleContent = {
78
78
  },
79
79
  {
80
80
  type: 'paragraph',
81
- html: 'Chaque application nécessite une autonomie, une charge utile et des plages de fonctionnement différentes. Une mission de photogrammétrie à 100 mètres sur un terrain de 10 hectares peut nécessiter plus de 20 minutes de vol autonome. Nos calculateurs d\'autonomie vous permettent de modéliser : le poids de la charge (caméra, capteur), le nombre de batteries et les cycles de vol par jour. De là, vous calculez le ROI : si vous avez besoin de 5 batteries de 4500 mAh à 50 € chacune, votre investissement initial en batteries est de 250 €. La mission le justifie-t-elle ? Nos outils vous aident à faire ce calcul.',
81
+ html: 'Chaque application nécessite une autonomie, une charge utile et des plages de fonctionnement différentes. Une mission de photogrammétrie à 100 mètres sur un terrain de 10 hectares peut nécessiter plus de 20 minutes de vol autonome. Nos calculateurs d\'autonomie vous permettent de modéliser: le poids de la charge (caméra, capteur), le nombre de batteries et les cycles de vol par jour. De là, vous calculez le ROI: si vous avez besoin de 5 batteries de 4500 mAh à 50 € chacune, votre investissement initial en batteries est de 250 €. La mission le justifie-t-elle ? Nos outils vous aident à faire ce calcul.',
82
82
  },
83
83
  {
84
84
  type: 'title',
85
- text: 'Communauté Radioamateur : Bandes HF, VHF, UHF et Satellites',
85
+ text: 'Communauté Radioamateur: Bandes HF, VHF, UHF et Satellites',
86
86
  level: 2,
87
87
  },
88
88
  {
@@ -91,7 +91,7 @@ export const content: CategoryLocaleContent = {
91
91
  },
92
92
  {
93
93
  type: 'paragraph',
94
- html: 'Comprendre comment calculer les antennes pour votre bande est essentiel. Un amateur souhaitant opérer sur 2 mètres (144-146 MHz) a besoin d\'une antenne demi-onde d\'environ 1 mètre. Notre générateur calcule cela : il vous suffit de saisir la fréquence, de préciser si vous souhaitez un quart d\'onde, une demi-onde ou une onde complète, et vous obtenez la dimension exacte. Construisez votre antenne, accordez-la et connectez-vous avec le monde en utilisant simplement une radio et une antenne artisanale. C\'est une technologie accessible et résiliente qui survit aux conflits, aux catastrophes et aux pannes de courant.',
94
+ html: 'Comprendre comment calculer les antennes pour votre bande est essentiel. Un amateur souhaitant opérer sur 2 mètres (144-146 MHz) a besoin d\'une antenne demi-onde d\'environ 1 mètre. Notre générateur calcule cela: il vous suffit de saisir la fréquence, de préciser si vous souhaitez un quart d\'onde, une demi-onde ou une onde complète, et vous obtenez la dimension exacte. Construisez votre antenne, accordez-la et connectez-vous avec le monde en utilisant simplement une radio et une antenne artisanale. C\'est une technologie accessible et résiliente qui survit aux conflits, aux catastrophes et aux pannes de courant.',
95
95
  },
96
96
  {
97
97
  type: 'title',
@@ -1,4 +1,4 @@
1
- import type { CategoryLocaleContent } from '../../types';
1
+ import type { CategoryLocaleContent } from '../../types';
2
2
 
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
@@ -12,11 +12,11 @@ export const content: CategoryLocaleContent = {
12
12
  },
13
13
  {
14
14
  type: 'paragraph',
15
- html: 'Радиолюбительство и полеты на дронах в 2026 году это области, где мастерство встречается с радиочастотной (РЧ) инженерией. В этом разделе мы предлагаем набор <strong>бесплатных онлайн-инструментов</strong>, разработанных для энтузиастов FPV, коммерческих пилотов беспилотников и операторов радиодиапазонов. Понимание ограничений вашего оборудования и законов распространения волн это разница между успешной миссией и дорогостоящим техническим сбоем.',
15
+ html: 'Радиолюбительство и полеты на дронах в 2026 году - это области, где мастерство встречается с радиочастотной (РЧ) инженерией. В этом разделе мы предлагаем набор <strong>бесплатных онлайн-инструментов</strong>, разработанных для энтузиастов FPV, коммерческих пилотов беспилотников и операторов радиодиапазонов. Понимание ограничений вашего оборудования и законов распространения волн - это разница между успешной миссией и дорогостоящим техническим сбоем.',
16
16
  },
17
17
  {
18
18
  type: 'paragraph',
19
- html: 'От планирования автономности полета до создания самодельных антенн и точной геопозиционной навигации наши утилиты дают вам уверенность, основанную на данных, для успешного запуска ваших проектов в воздух или в эфир.',
19
+ html: 'От планирования автономности полета до создания самодельных антенн и точной геопозиционной навигации - наши утилиты дают вам уверенность, основанную на данных, для успешного запуска ваших проектов в воздух или в эфир.',
20
20
  },
21
21
  {
22
22
  type: 'title',
@@ -87,7 +87,7 @@ export const content: CategoryLocaleContent = {
87
87
  },
88
88
  {
89
89
  type: 'paragraph',
90
- html: 'Радиолюбители (HAM radio) это глобальное сообщество, объединяющее миллионы операторов, общающихся без интернета. Они используют частотные диапазоны, выделенные международными организациями (IARU). Понимание того, как рассчитать антенну для вашего диапазона, имеет решающее значение.',
90
+ html: 'Радиолюбители (HAM radio) - это глобальное сообщество, объединяющее миллионы операторов, общающихся без интернета. Они используют частотные диапазоны, выделенные международными организациями (IARU). Понимание того, как рассчитать антенну для вашего диапазона, имеет решающее значение.',
91
91
  },
92
92
  {
93
93
  type: 'paragraph',
@@ -100,7 +100,7 @@ export const content: CategoryLocaleContent = {
100
100
  },
101
101
  {
102
102
  type: 'paragraph',
103
- html: 'В 2026 году интеграция дронов в общее воздушное пространство стала регулируемой реальностью. Способность любителей работать в соответствии с профессиональными стандартами обслуживания и технического планирования является ключом к безопасности. Эти инструменты часть вашего стремления к совершенству, которое должен проявлять каждый пилот и радиолюбитель.',
103
+ html: 'В 2026 году интеграция дронов в общее воздушное пространство стала регулируемой реальностью. Способность любителей работать в соответствии с профессиональными стандартами обслуживания и технического планирования является ключом к безопасности. Эти инструменты - часть вашего стремления к совершенству, которое должен проявлять каждый пилот и радиолюбитель.',
104
104
  },
105
105
  ],
106
106
  };
@@ -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,70 @@
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
+ expect(content).not.toContain('--');
68
+ });
69
+ });
70
+ });
@@ -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
+ });
@@ -1,4 +1,4 @@
1
- import type { AntennaLengthCalculatorLocaleContent } from '../index';
1
+ import type { AntennaLengthCalculatorLocaleContent } from '../index';
2
2
  import type { WithContext, SoftwareApplication, FAQPage, HowTo } from 'schema-dts';
3
3
  import { bibliography } from '../bibliography';
4
4
 
@@ -128,7 +128,7 @@ export const content: AntennaLengthCalculatorLocaleContent = {
128
128
  },
129
129
  {
130
130
  type: 'paragraph',
131
- html: 'Hochfrequenz (RF) reist mit Lichtgeschwindigkeit (ca. 300.000 Kilometer pro Sekunde). Damit eine Antenne Energie optimal abgeben oder empfangen kann, muss ihre physische Größe in direktem Verhältnis zur Strecke stehen, die ein vollständiger Zyklus der Welle zurücklegt der sogenannten <strong>Wellenlänge (λ)</strong>.',
131
+ html: 'Hochfrequenz (RF) reist mit Lichtgeschwindigkeit (ca. 300.000 Kilometer pro Sekunde). Damit eine Antenne Energie optimal abgeben oder empfangen kann, muss ihre physische Größe in direktem Verhältnis zur Strecke stehen, die ein vollständiger Zyklus der Welle zurücklegt - der sogenannten <strong>Wellenlänge (λ)</strong>.',
132
132
  },
133
133
  {
134
134
  type: 'paragraph',
@@ -1,4 +1,4 @@
1
- import type { AntennaLengthCalculatorLocaleContent } from '../index';
1
+ import type { AntennaLengthCalculatorLocaleContent } from '../index';
2
2
  import type { WithContext, SoftwareApplication, FAQPage, HowTo } from 'schema-dts';
3
3
  import { bibliography } from '../bibliography';
4
4
 
@@ -123,7 +123,7 @@ export const content: AntennaLengthCalculatorLocaleContent = {
123
123
  },
124
124
  {
125
125
  type: 'title',
126
- text: 'Concepts Fondamentaux : Longueur d\'Onde et Résonance',
126
+ text: 'Concepts Fondamentaux: Longueur d\'Onde et Résonance',
127
127
  level: 3,
128
128
  },
129
129
  {
@@ -137,9 +137,9 @@ export const content: AntennaLengthCalculatorLocaleContent = {
137
137
  {
138
138
  type: 'list',
139
139
  items: [
140
- 'Cuivre dénudé : A un Vf d\'environ 0,95.',
141
- 'Câbles isolés (PVC) : L\'isolation ralentit l\'onde, abaissant le facteur à 0,92 ou moins.',
142
- 'Tiges de cuivre massif : Étant plus épaisses et conductrices, le facteur monte légèrement à 0,97.',
140
+ 'Cuivre dénudé: A un Vf d\'environ 0,95.',
141
+ 'Câbles isolés (PVC): L\'isolation ralentit l\'onde, abaissant le facteur à 0,92 ou moins.',
142
+ 'Tiges de cuivre massif: Étant plus épaisses et conductrices, le facteur monte légèrement à 0,97.',
143
143
  ],
144
144
  },
145
145
  {
@@ -167,15 +167,15 @@ export const content: AntennaLengthCalculatorLocaleContent = {
167
167
  {
168
168
  type: 'list',
169
169
  items: [
170
- '5.8 GHz (Vidéo FPV) : Les longueurs sont minuscules (environ 12-13 mm pour le radiateur). Tout excès de soudure peut ruiner les performances.',
171
- '2.4 GHz (Contrôle et Wi-Fi) : Une bande saturée où l\'efficacité de l\'antenne est la clé pour éviter la perte de liaison (failsafe).',
172
- '868 MHz / 915 MHz (Longue Portée) : Utilisée par des systèmes comme Team BlackSheep Crossfire ou ExpressLRS. Les antennes ici sont plus grandes (environ 8 cm par bras) et permettent de traverser les obstacles plus facilement.',
173
- '433 MHz (UHF) : L\'ancienne norme longue portée, avec de grandes antennes idéales pour les vols de plusieurs kilomètres.',
170
+ '5.8 GHz (Vidéo FPV): Les longueurs sont minuscules (environ 12-13 mm pour le radiateur). Tout excès de soudure peut ruiner les performances.',
171
+ '2.4 GHz (Contrôle et Wi-Fi): Une bande saturée où l\'efficacité de l\'antenne est la clé pour éviter la perte de liaison (failsafe).',
172
+ '868 MHz / 915 MHz (Longue Portée): Utilisée par des systèmes comme Team BlackSheep Crossfire ou ExpressLRS. Les antennes ici sont plus grandes (environ 8 cm par bras) et permettent de traverser les obstacles plus facilement.',
173
+ '433 MHz (UHF): L\'ancienne norme longue portée, avec de grandes antennes idéales pour les vols de plusieurs kilomètres.',
174
174
  ],
175
175
  },
176
176
  {
177
177
  type: 'title',
178
- text: 'Référence Technique : Tableau SWR et Pertes',
178
+ text: 'Référence Technique: Tableau SWR et Pertes',
179
179
  level: 3,
180
180
  },
181
181
  {
@@ -204,7 +204,7 @@ export const content: AntennaLengthCalculatorLocaleContent = {
204
204
  },
205
205
  {
206
206
  type: 'title',
207
- text: 'Le danger d\'un SWR élevé : Protégez votre VTx',
207
+ text: 'Le danger d\'un SWR élevé: Protégez votre VTx',
208
208
  level: 3,
209
209
  },
210
210
  {
@@ -217,7 +217,7 @@ export const content: AntennaLengthCalculatorLocaleContent = {
217
217
  },
218
218
  {
219
219
  type: 'title',
220
- text: 'Harmoniques : Comprendre les Interférences',
220
+ text: 'Harmoniques: Comprendre les Interférences',
221
221
  level: 3,
222
222
  },
223
223
  {
@@ -1,4 +1,4 @@
1
- import type { AntennaLengthCalculatorLocaleContent } from '../index';
1
+ import type { AntennaLengthCalculatorLocaleContent } from '../index';
2
2
  import type { WithContext, SoftwareApplication, FAQPage, HowTo } from 'schema-dts';
3
3
  import { bibliography } from '../bibliography';
4
4
 
@@ -115,7 +115,7 @@ export const content: AntennaLengthCalculatorLocaleContent = {
115
115
  },
116
116
  {
117
117
  type: 'paragraph',
118
- html: 'Если вы когда-нибудь задавались вопросом, почему антенны гоночных квадрокоптеров (FPV), контроллеры дальнего действия (ELRS/Crossfire) или даже ваш Wi-Fi роутер имеют такие специфические длины, ответ кроется в физике резонанса. Антенна это не просто кусок проводящего провода; это компонент, который должен быть "настроен" на частоту электромагнитной волны, с которой он работает.',
118
+ html: 'Если вы когда-нибудь задавались вопросом, почему антенны гоночных квадрокоптеров (FPV), контроллеры дальнего действия (ELRS/Crossfire) или даже ваш Wi-Fi роутер имеют такие специфические длины, ответ кроется в физике резонанса. Антенна - это не просто кусок проводящего провода; это компонент, который должен быть "настроен" на частоту электромагнитной волны, с которой он работает.',
119
119
  },
120
120
  {
121
121
  type: 'paragraph',
@@ -132,7 +132,7 @@ export const content: AntennaLengthCalculatorLocaleContent = {
132
132
  },
133
133
  {
134
134
  type: 'paragraph',
135
- html: 'Основная формула для расчета длины волны λ = v / f, где «v» скорость распространения, а «f» частота. Однако в реальном мире электричество распространяется через металлы немного медленнее, чем в вакууме. Вот тут в игру вступает <strong>коэффициент укорочения (Vf)</strong>.',
135
+ html: 'Основная формула для расчета длины волны - λ = v / f, где "v" - скорость распространения, а "f" - частота. Однако в реальном мире электричество распространяется через металлы немного медленнее, чем в вакууме. Вот тут в игру вступает <strong>коэффициент укорочения (Vf)</strong>.',
136
136
  },
137
137
  {
138
138
  type: 'list',
@@ -209,11 +209,11 @@ export const content: AntennaLengthCalculatorLocaleContent = {
209
209
  },
210
210
  {
211
211
  type: 'paragraph',
212
- html: 'Почему мы так настаиваем на точности? Если антенна не имеет правильной длины, она не сможет излучать всю энергию, посылаемую видеопередатчиком (VTx). Эта энергия «отскакивает» обратно к передатчику в виде тепла.',
212
+ html: 'Почему мы так настаиваем на точности? Если антенна не имеет правильной длины, она не сможет излучать всю энергию, посылаемую видеопередатчиком (VTx). Эта энергия "отскакивает" обратно к передатчику в виде тепла.',
213
213
  },
214
214
  {
215
215
  type: 'paragraph',
216
- html: 'Высокий КСВ причина номер один сгоревших передатчиков. Если вы летите без антенны или с плохо обрезанной, внутренние компоненты перегреются за считанные секунды, что сделает ваше оборудование непригодным для использования. Использование этого инструмента для проверки ваших обрезок лучшая инвестиция в безопасность вашего дрона.',
216
+ html: 'Высокий КСВ - причина номер один сгоревших передатчиков. Если вы летите без антенны или с плохо обрезанной, внутренние компоненты перегреются за считанные секунды, что сделает ваше оборудование непригодным для использования. Использование этого инструмента для проверки ваших обрезок - лучшая инвестиция в безопасность вашего дрона.',
217
217
  },
218
218
  {
219
219
  type: 'title',
@@ -226,7 +226,7 @@ export const content: AntennaLengthCalculatorLocaleContent = {
226
226
  },
227
227
  {
228
228
  type: 'paragraph',
229
- html: 'Важно это знать, потому что даже если ваша антенна излучает на частоте 868 МГц, вы можете создавать «шум» или помехи на более высоких частотах, если передатчик плохо отфильтрован. Калькулятор гармоник поможет вам предсказать, где могут появиться эти фантомные сигналы.',
229
+ html: 'Важно это знать, потому что даже если ваша антенна излучает на частоте 868 МГц, вы можете создавать "шум" или помехи на более высоких частотах, если передатчик плохо отфильтрован. Калькулятор гармоник поможет вам предсказать, где могут появиться эти фантомные сигналы.',
230
230
  },
231
231
  ],
232
232
  faq: [
@@ -1,4 +1,4 @@
1
- import type { AntennaLengthCalculatorLocaleContent } from '../index';
1
+ import type { AntennaLengthCalculatorLocaleContent } from '../index';
2
2
  import type { WithContext, SoftwareApplication, FAQPage, HowTo } from 'schema-dts';
3
3
  import { bibliography } from '../bibliography';
4
4
 
@@ -32,7 +32,7 @@ const howToSteps = [
32
32
  },
33
33
  {
34
34
  name: 'Nasıl Kurulacağına Kararılma Tipi',
35
- text: 'Oluşturulacak türden seçeceğin için : Tam Takım ( ikiye oranlı dalgalık) 1 yada 2, mi Yoksam da sade basit Tek uç( çeyreklik yagıya denk gelen dikey düz) 1 veya 4 ü onayla.',
35
+ text: 'Oluşturulacak türden seçeceğin için: Tam Takım ( ikiye oranlı dalgalık) 1 yada 2, mi Yoksam da sade basit Tek uç( çeyreklik yagıya denk gelen dikey düz) 1 veya 4 ü onayla.',
36
36
  },
37
37
  {
38
38
  name: 'Kaplama Veya Kablonun Kendi Menşei Seçimi',
@@ -255,7 +255,7 @@ export const content: AntennaLengthCalculatorLocaleContent = {
255
255
  },
256
256
  {
257
257
  name: 'Nasıl Kurulacağına Kararılma Tipi',
258
- text: 'Oluşturulacak türden seçeceğin için : Tam Takım ( ikiye oranlı dalgalık) 1 yada 2, mi Yoksam da sade basit Tek uç( çeyreklik yagıya denk gelen dikey düz) 1 veya 4 ü onayla.',
258
+ text: 'Oluşturulacak türden seçeceğin için: Tam Takım ( ikiye oranlı dalgalık) 1 yada 2, mi Yoksam da sade basit Tek uç( çeyreklik yagıya denk gelen dikey düz) 1 veya 4 ü onayla.',
259
259
  },
260
260
  {
261
261
  name: 'Kaplama Veya Kablonun Kendi Menşei Seçimi',