@jjlmoya/utils-diy 1.15.0 → 1.17.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 (112) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/fr.ts +5 -5
  3. package/src/entries.ts +3 -1
  4. package/src/index.ts +2 -0
  5. package/src/layouts/PreviewLayout.astro +1 -0
  6. package/src/tests/diacritics_density.test.ts +118 -0
  7. package/src/tests/inverted_punctuation.test.ts +84 -0
  8. package/src/tests/locale_completeness.test.ts +2 -2
  9. package/src/tests/no_en_dash.test.ts +70 -0
  10. package/src/tests/script_density.test.ts +94 -0
  11. package/src/tests/tool_validation.test.ts +2 -2
  12. package/src/tool/balusterCalculator/i18n/de.ts +7 -7
  13. package/src/tool/balusterCalculator/i18n/en.ts +4 -4
  14. package/src/tool/balusterCalculator/i18n/es.ts +2 -2
  15. package/src/tool/balusterCalculator/i18n/fr.ts +3 -3
  16. package/src/tool/balusterCalculator/i18n/it.ts +16 -16
  17. package/src/tool/balusterCalculator/i18n/ru.ts +14 -14
  18. package/src/tool/balusterCalculator/i18n/tr.ts +5 -5
  19. package/src/tool/clayCalculator/i18n/fr.ts +3 -3
  20. package/src/tool/clayCalculator/i18n/ru.ts +3 -3
  21. package/src/tool/concreteCalculator/i18n/en.ts +4 -4
  22. package/src/tool/concreteCalculator/i18n/fr.ts +2 -2
  23. package/src/tool/concreteCalculator/i18n/ko.ts +2 -2
  24. package/src/tool/concreteCalculator/i18n/ru.ts +3 -3
  25. package/src/tool/cutOptimizer/i18n/en.ts +4 -4
  26. package/src/tool/cutOptimizer/i18n/fr.ts +2 -2
  27. package/src/tool/cutOptimizer/i18n/ru.ts +2 -2
  28. package/src/tool/drillCalculator/bibliography.ts +2 -2
  29. package/src/tool/drillCalculator/i18n/ru.ts +5 -5
  30. package/src/tool/drillCalculator/i18n/zh.ts +2 -2
  31. package/src/tool/drillSharpener/i18n/ru.ts +3 -3
  32. package/src/tool/epoxyCalculator/epoxy-resin-calculator.css +81 -1
  33. package/src/tool/epoxyCalculator/i18n/de.ts +2 -2
  34. package/src/tool/epoxyCalculator/i18n/en.ts +2 -2
  35. package/src/tool/epoxyCalculator/i18n/es.ts +2 -2
  36. package/src/tool/epoxyCalculator/i18n/fr.ts +3 -3
  37. package/src/tool/epoxyCalculator/i18n/id.ts +2 -2
  38. package/src/tool/epoxyCalculator/i18n/it.ts +2 -2
  39. package/src/tool/epoxyCalculator/i18n/ja.ts +2 -2
  40. package/src/tool/epoxyCalculator/i18n/ko.ts +2 -2
  41. package/src/tool/epoxyCalculator/i18n/nl.ts +2 -2
  42. package/src/tool/epoxyCalculator/i18n/pl.ts +2 -2
  43. package/src/tool/epoxyCalculator/i18n/pt.ts +2 -2
  44. package/src/tool/epoxyCalculator/i18n/ru.ts +5 -5
  45. package/src/tool/epoxyCalculator/i18n/sv.ts +2 -2
  46. package/src/tool/epoxyCalculator/i18n/tr.ts +2 -2
  47. package/src/tool/epoxyCalculator/i18n/zh.ts +4 -4
  48. package/src/tool/furnitureFit/i18n/de.ts +2 -2
  49. package/src/tool/furnitureFit/i18n/en.ts +4 -4
  50. package/src/tool/furnitureFit/i18n/es.ts +2 -2
  51. package/src/tool/furnitureFit/i18n/fr.ts +3 -3
  52. package/src/tool/furnitureFit/i18n/id.ts +2 -2
  53. package/src/tool/furnitureFit/i18n/it.ts +2 -2
  54. package/src/tool/furnitureFit/i18n/ja.ts +2 -2
  55. package/src/tool/furnitureFit/i18n/ko.ts +2 -2
  56. package/src/tool/furnitureFit/i18n/nl.ts +2 -2
  57. package/src/tool/furnitureFit/i18n/pl.ts +2 -2
  58. package/src/tool/furnitureFit/i18n/pt.ts +2 -2
  59. package/src/tool/furnitureFit/i18n/ru.ts +6 -6
  60. package/src/tool/furnitureFit/i18n/sv.ts +2 -2
  61. package/src/tool/furnitureFit/i18n/tr.ts +2 -2
  62. package/src/tool/furnitureFit/i18n/zh.ts +2 -2
  63. package/src/tool/mortarCalculator/i18n/en.ts +3 -3
  64. package/src/tool/mortarCalculator/i18n/ru.ts +6 -6
  65. package/src/tool/mortarCalculator/i18n/zh.ts +3 -3
  66. package/src/tool/passepartoutCalculator/i18n/en.ts +4 -4
  67. package/src/tool/passepartoutCalculator/i18n/ru.ts +7 -7
  68. package/src/tool/passepartoutCalculator/i18n/zh.ts +6 -6
  69. package/src/tool/passepartoutCalculator/passepartout-calculator.css +99 -3
  70. package/src/tool/pythagoreanRightAngleCalculator/i18n/fr.ts +3 -3
  71. package/src/tool/pythagoreanRightAngleCalculator/i18n/pl.ts +2 -2
  72. package/src/tool/pythagoreanRightAngleCalculator/i18n/ru.ts +5 -5
  73. package/src/tool/stairCalculator/i18n/ru.ts +6 -6
  74. package/src/tool/stairCalculator/stair-calculator.css +76 -5
  75. package/src/tool/thermalExpansionCalculator/bibliography.ts +4 -4
  76. package/src/tool/thermalExpansionCalculator/i18n/en.ts +2 -2
  77. package/src/tool/thermalExpansionCalculator/i18n/fr.ts +3 -3
  78. package/src/tool/thermalExpansionCalculator/i18n/ru.ts +4 -4
  79. package/src/tool/twoStrokeMixtureCalculator/bibliography.astro +6 -0
  80. package/src/tool/twoStrokeMixtureCalculator/bibliography.ts +12 -0
  81. package/src/tool/twoStrokeMixtureCalculator/component.astro +266 -0
  82. package/src/tool/twoStrokeMixtureCalculator/entry.ts +24 -0
  83. package/src/tool/twoStrokeMixtureCalculator/i18n/de.ts +194 -0
  84. package/src/tool/twoStrokeMixtureCalculator/i18n/en.ts +194 -0
  85. package/src/tool/twoStrokeMixtureCalculator/i18n/es.ts +194 -0
  86. package/src/tool/twoStrokeMixtureCalculator/i18n/fr.ts +194 -0
  87. package/src/tool/twoStrokeMixtureCalculator/i18n/id.ts +194 -0
  88. package/src/tool/twoStrokeMixtureCalculator/i18n/it.ts +194 -0
  89. package/src/tool/twoStrokeMixtureCalculator/i18n/ja.ts +194 -0
  90. package/src/tool/twoStrokeMixtureCalculator/i18n/ko.ts +194 -0
  91. package/src/tool/twoStrokeMixtureCalculator/i18n/nl.ts +194 -0
  92. package/src/tool/twoStrokeMixtureCalculator/i18n/pl.ts +194 -0
  93. package/src/tool/twoStrokeMixtureCalculator/i18n/pt.ts +194 -0
  94. package/src/tool/twoStrokeMixtureCalculator/i18n/ru.ts +194 -0
  95. package/src/tool/twoStrokeMixtureCalculator/i18n/sv.ts +194 -0
  96. package/src/tool/twoStrokeMixtureCalculator/i18n/tr.ts +194 -0
  97. package/src/tool/twoStrokeMixtureCalculator/i18n/zh.ts +194 -0
  98. package/src/tool/twoStrokeMixtureCalculator/index.ts +11 -0
  99. package/src/tool/twoStrokeMixtureCalculator/logic.ts +92 -0
  100. package/src/tool/twoStrokeMixtureCalculator/seo.astro +15 -0
  101. package/src/tool/twoStrokeMixtureCalculator/two-stroke-fuel-mixture-calculator.css +405 -0
  102. package/src/tool/twoStrokeMixtureCalculator/ui.ts +81 -0
  103. package/src/tool/voltageDropCalculator/i18n/en.ts +2 -2
  104. package/src/tool/voltageDropCalculator/i18n/es.ts +4 -4
  105. package/src/tool/voltageDropCalculator/i18n/ru.ts +4 -4
  106. package/src/tool/workshopFractionConverter/bibliography.ts +2 -2
  107. package/src/tool/workshopFractionConverter/i18n/de.ts +2 -2
  108. package/src/tool/workshopFractionConverter/i18n/es.ts +2 -2
  109. package/src/tool/workshopFractionConverter/i18n/fr.ts +2 -2
  110. package/src/tool/workshopFractionConverter/i18n/ru.ts +9 -9
  111. package/src/tool/workshopFractionConverter/i18n/zh.ts +6 -6
  112. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-diy",
3
- "version": "1.15.0",
3
+ "version": "1.17.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: 'diy',
@@ -8,7 +8,7 @@ export const content: CategoryLocaleContent = {
8
8
  seo: [
9
9
  {
10
10
  type: 'title',
11
- text: 'Ingénierie Domestique et Projets DIY : Du Design à la Réalité',
11
+ text: 'Ingénierie Domestique et Projets DIY: Du Design à la Réalité',
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: 'Construction et Rénovation : Béton, Mortiers et Escaliers',
24
+ text: 'Construction et Rénovation: Béton, Mortiers et Escaliers',
25
25
  level: 2,
26
26
  },
27
27
  {
@@ -39,7 +39,7 @@ export const content: CategoryLocaleContent = {
39
39
  },
40
40
  {
41
41
  type: 'title',
42
- text: 'Artisanat et Nouveaux Matériaux : Argile et Résine Époxy',
42
+ text: 'Artisanat et Nouveaux Matériaux: Argile et Résine Époxy',
43
43
  level: 2,
44
44
  },
45
45
  {
@@ -71,7 +71,7 @@ export const content: CategoryLocaleContent = {
71
71
  },
72
72
  {
73
73
  type: 'title',
74
- text: 'Gestion du Budget : Décomposition des Coûts et Optimisation Économique',
74
+ text: 'Gestion du Budget: Décomposition des Coûts et Optimisation Économique',
75
75
  level: 2,
76
76
  },
77
77
  {
package/src/entries.ts CHANGED
@@ -16,6 +16,7 @@ export { thermalExpansionCalculator } from './tool/thermalExpansionCalculator/en
16
16
  export { voltageDropCalculator } from './tool/voltageDropCalculator/entry';
17
17
  export { workshopFractionConverter } from './tool/workshopFractionConverter/entry';
18
18
  export { pythagoreanRightAngleCalculator } from './tool/pythagoreanRightAngleCalculator/entry';
19
+ export { twoStrokeMixtureCalculator } from './tool/twoStrokeMixtureCalculator/entry';
19
20
  export { diyCategory } from './category';
20
21
  import { balusterCalculator } from './tool/balusterCalculator/entry';
21
22
  import { clayCalculator } from './tool/clayCalculator/entry';
@@ -32,4 +33,5 @@ import { thermalExpansionCalculator } from './tool/thermalExpansionCalculator/en
32
33
  import { voltageDropCalculator } from './tool/voltageDropCalculator/entry';
33
34
  import { workshopFractionConverter } from './tool/workshopFractionConverter/entry';
34
35
  import { pythagoreanRightAngleCalculator } from './tool/pythagoreanRightAngleCalculator/entry';
35
- export const ALL_ENTRIES = [balusterCalculator, clayCalculator, concreteCalculator, cutOptimizer, drillCalculator, drillSharpener, epoxyCalculator, furnitureFit, mortarCalculator, passepartoutCalculator, stairCalculator, thermalExpansionCalculator, voltageDropCalculator, workshopFractionConverter, pythagoreanRightAngleCalculator];
36
+ import { twoStrokeMixtureCalculator } from './tool/twoStrokeMixtureCalculator/entry';
37
+ export const ALL_ENTRIES = [balusterCalculator, clayCalculator, concreteCalculator, cutOptimizer, drillCalculator, drillSharpener, epoxyCalculator, furnitureFit, mortarCalculator, passepartoutCalculator, stairCalculator, thermalExpansionCalculator, voltageDropCalculator, workshopFractionConverter, pythagoreanRightAngleCalculator, twoStrokeMixtureCalculator];
package/src/index.ts CHANGED
@@ -46,3 +46,5 @@ export { DRILL_SHARPENER_TOOL } from './tool/drillSharpener/index';
46
46
  export { WORKSHOP_FRACTION_CONVERTER_TOOL } from './tool/workshopFractionConverter/index';
47
47
 
48
48
  export { PYTHAGOREAN_RIGHT_ANGLE_CALCULATOR_TOOL } from './tool/pythagoreanRightAngleCalculator/index';
49
+
50
+ export { TWO_STROKE_MIXTURE_CALCULATOR_TOOL } from './tool/twoStrokeMixtureCalculator/index';
@@ -78,6 +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
+ font-family: Inter, sans-serif;
81
82
  }
82
83
 
83
84
  main {
@@ -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
+ });
@@ -33,8 +33,8 @@ describe('Locale Completeness Validation', () => {
33
33
  });
34
34
  });
35
35
 
36
- it('all 15 tools registered', () => {
37
- expect(ALL_TOOLS.length).toBe(15);
36
+ it('all 16 tools registered', () => {
37
+ expect(ALL_TOOLS.length).toBe(16);
38
38
  });
39
39
  });
40
40
 
@@ -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
+ });
@@ -4,8 +4,8 @@ import { diyCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 15 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(15);
7
+ it('should have 16 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(16);
9
9
  });
10
10
 
11
11
  it('diyCategory should be defined', () => {
@@ -1,4 +1,4 @@
1
- import type { WithContext, FAQPage, HowToThing, SoftwareApplication } from 'schema-dts';
1
+ import type { WithContext, FAQPage, HowToThing, SoftwareApplication } from 'schema-dts';
2
2
  import type { ToolLocaleContent } from '../../../types';
3
3
  import type { BalusterCalculatorUI } from '../ui';
4
4
  import { bibliography } from '../bibliography';
@@ -116,7 +116,7 @@ export const content: ToolLocaleContent<BalusterCalculatorUI> = {
116
116
  },
117
117
  {
118
118
  type: 'paragraph',
119
- html: 'Bevor Sie das erste Stück Holz oder Metall schneiden, müssen Sie das Warum hinter der Mathematik verstehen. Die Bauvorschriften in vielen Ländern, wie auch der IRC (International Residential Code) in den USA, legen eine klare Norm fest: <em>„Der lichte Abstand zwischen vertikalen Elementen darf den Durchgang einer Kugel mit 10 cm (4 Zoll) Durchmesser nicht zulassen.“</em>',
119
+ html: 'Bevor Sie das erste Stück Holz oder Metall schneiden, müssen Sie das "Warum" hinter der Mathematik verstehen. Die Bauvorschriften in vielen Ländern, wie auch der IRC (International Residential Code) in den USA, legen eine klare Norm fest: <em>"Der lichte Abstand zwischen vertikalen Elementen darf den Durchgang einer Kugel mit 10 cm (4 Zoll) Durchmesser nicht zulassen."</em>',
120
120
  },
121
121
  {
122
122
  type: 'paragraph',
@@ -129,7 +129,7 @@ export const content: ToolLocaleContent<BalusterCalculatorUI> = {
129
129
  },
130
130
  {
131
131
  type: 'paragraph',
132
- html: 'In der Programmierung und im Bauwesen gibt es ein klassisches Problem namens <em>„Fencepost Error“</em> (Zaunpfostenfehler). Wenn Sie einen 10 Meter langen Zaun mit Pfosten alle 1 Meter bauen wollen, wie viele Pfosten benötigen Sie? Die intuitive Antwort ist 10, aber die richtige ist 11.',
132
+ html: 'In der Programmierung und im Bauwesen gibt es ein klassisches Problem namens <em>"Fencepost Error"</em> (Zaunpfostenfehler). Wenn Sie einen 10 Meter langen Zaun mit Pfosten alle 1 Meter bauen wollen, wie viele Pfosten benötigen Sie? Die intuitive Antwort ist 10, aber die richtige ist 11.',
133
133
  },
134
134
  {
135
135
  type: 'paragraph',
@@ -148,7 +148,7 @@ export const content: ToolLocaleContent<BalusterCalculatorUI> = {
148
148
  type: 'card',
149
149
  icon: 'mdi:function-variant',
150
150
  title: 'Schritt B: Exakten Abstand neuberechnen',
151
- html: '<p class="bc-formula">g = (L - n × w) / (n + 1)</p><p>Sobald wir die exakte Anzahl der Baluster (n) kennen, berechnen wir, wie viel Platz „übrig bleibt“, wenn wir die Breite des gesamten Materials abziehen, und verteilen diesen gleichmäßig auf die Lücken (n+1).</p>',
151
+ html: '<p class="bc-formula">g = (L - n × w) / (n + 1)</p><p>Sobald wir die exakte Anzahl der Baluster (n) kennen, berechnen wir, wie viel Platz "übrig bleibt", wenn wir die Breite des gesamten Materials abziehen, und verteilen diesen gleichmäßig auf die Lücken (n+1).</p>',
152
152
  },
153
153
  ],
154
154
  },
@@ -212,7 +212,7 @@ export const content: ToolLocaleContent<BalusterCalculatorUI> = {
212
212
  type: 'card',
213
213
  icon: 'mdi:help-circle-outline',
214
214
  title: 'Was passiert, wenn die letzte Lücke nicht passt?',
215
- html: '<p>Wenn Sie unseren Rechner verwendet haben, sollte dies nicht passieren. In der realen Welt verbiegt sich jedoch Holz, und Pfosten sind nicht immer gerade. Wenn Ihnen am Ende 1-2 mm fehlen oder zu viel sind, ist das unmerklich. Wenn Ihnen 1 cm fehlt, überprüfen Sie Ihre Abstandshalter; wahrscheinlich haben Sie sich bei jedem Schritt Millimeter eingefressen“.</p>',
215
+ html: '<p>Wenn Sie unseren Rechner verwendet haben, sollte dies nicht passieren. In der realen Welt verbiegt sich jedoch Holz, und Pfosten sind nicht immer gerade. Wenn Ihnen am Ende 1-2 mm fehlen oder zu viel sind, ist das unmerklich. Wenn Ihnen 1 cm fehlt, überprüfen Sie Ihre Abstandshalter; wahrscheinlich haben Sie sich bei jedem Schritt Millimeter "eingefressen".</p>',
216
216
  },
217
217
  {
218
218
  type: 'card',
@@ -224,13 +224,13 @@ export const content: ToolLocaleContent<BalusterCalculatorUI> = {
224
224
  type: 'card',
225
225
  icon: 'mdi:wrench-outline',
226
226
  title: 'Holz, Metall oder Glas?',
227
- html: '<p>Dieser Rechner funktioniert für <strong>jedes sich wiederholende vertikale Element</strong>. Wenn Sie horizontale Stahlseile verwenden, sind die Vorschriften anders (normalerweise dürfen sie nicht zum Klettern einladen, Leitereffekt). Bei Glas wird es als durchgehende Platte betrachtet, und es gilt kein Abstand, sondern die Schlagfestigkeit.</p>',
227
+ html: '<p>Dieser Rechner funktioniert für <strong>jedes sich wiederholende vertikale Element</strong>. Wenn Sie horizontale Stahlseile verwenden, sind die Vorschriften anders (normalerweise dürfen sie nicht zum Klettern einladen, "Leitereffekt"). Bei Glas wird es als durchgehende Platte betrachtet, und es gilt kein Abstand, sondern die Schlagfestigkeit.</p>',
228
228
  },
229
229
  {
230
230
  type: 'card',
231
231
  icon: 'mdi:format-quote-close',
232
232
  title: 'Mies van der Rohe',
233
- html: '<p><em>„Architektur ist der Wille der Epoche, in den Raum übersetzt.“</em></p><p>Wir hoffen, dass dieses Tool Ihnen hilft, sicherere und schönere Räume zu bauen. Denken Sie immer daran, die örtlichen Bauvorschriften Ihrer Gemeinde zu konsultieren, da es spezifische Variationen bei der Mindesthöhe (normalerweise 90110 cm) oder den Belastungen geben kann.</p>',
233
+ html: '<p><em>"Architektur ist der Wille der Epoche, in den Raum übersetzt."</em></p><p>Wir hoffen, dass dieses Tool Ihnen hilft, sicherere und schönere Räume zu bauen. Denken Sie immer daran, die örtlichen Bauvorschriften Ihrer Gemeinde zu konsultieren, da es spezifische Variationen bei der Mindesthöhe (normalerweise 90-110 cm) oder den Belastungen geben kann.</p>',
234
234
  },
235
235
  ],
236
236
  ui: {
@@ -1,4 +1,4 @@
1
- import type { WithContext, FAQPage, HowToThing, SoftwareApplication } from 'schema-dts';
1
+ import type { WithContext, FAQPage, HowToThing, SoftwareApplication } from 'schema-dts';
2
2
  import type { ToolLocaleContent } from '../../../types';
3
3
  import type { BalusterCalculatorUI } from '../ui';
4
4
  import { bibliography } from '../bibliography';
@@ -120,7 +120,7 @@ export const content: ToolLocaleContent<BalusterCalculatorUI> = {
120
120
  },
121
121
  {
122
122
  type: 'paragraph',
123
- html: "This measurement is not arbitrary. It is the average diameter of a young child's head (generally under 1 year old). If a child can fit their body through but their head gets stuck, the risk of suffocation is extremely high. If they can fit their head through, they can fall into the void. Therefore, the goal of our <strong>Baluster Calculator</strong> is not just aesthetic it saves lives.",
123
+ html: "This measurement is not arbitrary. It is the average diameter of a young child's head (generally under 1 year old). If a child can fit their body through but their head gets stuck, the risk of suffocation is extremely high. If they can fit their head through, they can fall into the void. Therefore, the goal of our <strong>Baluster Calculator</strong> is not just aesthetic - it saves lives.",
124
124
  },
125
125
  {
126
126
  type: 'title',
@@ -212,7 +212,7 @@ export const content: ToolLocaleContent<BalusterCalculatorUI> = {
212
212
  type: 'card',
213
213
  icon: 'mdi:help-circle-outline',
214
214
  title: 'What if the last gap does not fit?',
215
- html: "<p>If you've used our calculator, this shouldn't happen. However, in the real world, wood warps and posts aren't always straight. If at the end you're off by 1-2 mm, it's imperceptible. If you're off by 1 cm, check your spacer blocks you've probably been losing mm at each step.</p>",
215
+ html: "<p>If you've used our calculator, this shouldn't happen. However, in the real world, wood warps and posts aren't always straight. If at the end you're off by 1-2 mm, it's imperceptible. If you're off by 1 cm, check your spacer blocks - you've probably been losing mm at each step.</p>",
216
216
  },
217
217
  {
218
218
  type: 'card',
@@ -224,7 +224,7 @@ export const content: ToolLocaleContent<BalusterCalculatorUI> = {
224
224
  type: 'card',
225
225
  icon: 'mdi:wrench-outline',
226
226
  title: 'Wood, Metal or Glass?',
227
- html: '<p>This calculator works for <strong>any repetitive vertical element</strong>. If you use horizontal steel cables, different regulations apply (they typically must not allow climbing, "ladder effect"). For glass, it is considered a continuous panel and spacing does not apply impact resistance does.</p>',
227
+ html: '<p>This calculator works for <strong>any repetitive vertical element</strong>. If you use horizontal steel cables, different regulations apply (they typically must not allow climbing, "ladder effect"). For glass, it is considered a continuous panel and spacing does not apply - impact resistance does.</p>',
228
228
  },
229
229
  {
230
230
  type: 'card',
@@ -1,4 +1,4 @@
1
- import type { WithContext, FAQPage, HowToThing, SoftwareApplication } from 'schema-dts';
1
+ import type { WithContext, FAQPage, HowToThing, SoftwareApplication } from 'schema-dts';
2
2
  import type { ToolLocaleContent } from '../../../types';
3
3
  import type { BalusterCalculatorUI } from '../ui';
4
4
  import { bibliography } from '../bibliography';
@@ -15,7 +15,7 @@ const faqData = [
15
15
  'En España (CTE) y en la mayoría de normativas internacionales, el espacio libre entre elementos de una barandilla no debe permitir el paso de una esfera de 10 cm de diámetro para evitar accidentes infantiles.',
16
16
  },
17
17
  {
18
- question: '¿Cómo se mide el espaciado: entre centros o huecos?',
18
+ question: '¿Cómo se mide el espaciado: ¿entre centros o huecos?',
19
19
  answer:
20
20
  "Nuestra calculadora proporciona ambos. El 'espaciado libre' es el hueco real entre barrotes (vital por seguridad), mientras que la 'distancia entre centros' (on-center) es la que marca dónde taladrar o fijar cada balustre.",
21
21
  },
@@ -1,4 +1,4 @@
1
- import type { WithContext, FAQPage, HowToThing, SoftwareApplication } from 'schema-dts';
1
+ import type { WithContext, FAQPage, HowToThing, SoftwareApplication } from 'schema-dts';
2
2
  import type { ToolLocaleContent } from '../../../types';
3
3
  import type { BalusterCalculatorUI } from '../ui';
4
4
  import { bibliography } from '../bibliography';
@@ -120,7 +120,7 @@ export const content: ToolLocaleContent<BalusterCalculatorUI> = {
120
120
  },
121
121
  {
122
122
  type: 'paragraph',
123
- html: "Cette mesure n'est pas arbitraire. C'est le diamètre moyen de la tête d'un jeune enfant (généralement de moins d'1 an). Si un enfant peut passer son corps mais que sa tête se coince, le risque d'asphyxie est extrêmement élevé. L'objectif de notre <strong>Calculateur de Balustres</strong> n'est donc pas seulement esthétique il sauve des vies.",
123
+ html: "Cette mesure n'est pas arbitraire. C'est le diamètre moyen de la tête d'un jeune enfant (généralement de moins d'1 an). Si un enfant peut passer son corps mais que sa tête se coince, le risque d'asphyxie est extrêmement élevé. L'objectif de notre <strong>Calculateur de Balustres</strong> n'est donc pas seulement esthétique - il sauve des vies.",
124
124
  },
125
125
  {
126
126
  type: 'title',
@@ -224,7 +224,7 @@ export const content: ToolLocaleContent<BalusterCalculatorUI> = {
224
224
  type: 'card',
225
225
  icon: 'mdi:wrench-outline',
226
226
  title: 'Bois, Métal ou Verre?',
227
- html: '<p>Ce calculateur fonctionne pour <strong>tout élément vertical répétitif</strong>. Pour les câbles en acier horizontaux, une réglementation différente s\'applique. Pour le verre, c\'est un panneau continu et l\'espacement ne s\'applique pas c\'est la résistance aux chocs qui compte.</p>',
227
+ html: '<p>Ce calculateur fonctionne pour <strong>tout élément vertical répétitif</strong>. Pour les câbles en acier horizontaux, une réglementation différente s\'applique. Pour le verre, c\'est un panneau continu et l\'espacement ne s\'applique pas - c\'est la résistance aux chocs qui compte.</p>',
228
228
  },
229
229
  {
230
230
  type: 'card',