@jjlmoya/utils-creative 1.20.0 → 1.22.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 (148) hide show
  1. package/package.json +4 -2
  2. package/src/category/i18n/de.ts +1 -1
  3. package/src/category/i18n/ru.ts +1 -1
  4. package/src/data.ts +2 -1
  5. package/src/entries.ts +3 -1
  6. package/src/index.ts +3 -0
  7. package/src/layouts/PreviewLayout.astro +7 -2
  8. package/src/tests/diacritics_density.test.ts +118 -0
  9. package/src/tests/inverted_punctuation.test.ts +84 -0
  10. package/src/tests/locale_completeness.test.ts +2 -14
  11. package/src/tests/no_en_dash.test.ts +70 -0
  12. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  13. package/src/tests/script_density.test.ts +94 -0
  14. package/src/tests/tool_validation.test.ts +2 -2
  15. package/src/tool/bead-pattern-generator/bibliography.astro +2 -12
  16. package/src/tool/bead-pattern-generator/bibliography.ts +5 -0
  17. package/src/tool/bead-pattern-generator/component.astro +2 -2
  18. package/src/tool/bead-pattern-generator/entry.ts +0 -1
  19. package/src/tool/bead-pattern-generator/i18n/de.ts +4 -8
  20. package/src/tool/bead-pattern-generator/i18n/en.ts +4 -8
  21. package/src/tool/bead-pattern-generator/i18n/es.ts +4 -8
  22. package/src/tool/bead-pattern-generator/i18n/fr.ts +5 -9
  23. package/src/tool/bead-pattern-generator/i18n/id.ts +4 -8
  24. package/src/tool/bead-pattern-generator/i18n/it.ts +4 -8
  25. package/src/tool/bead-pattern-generator/i18n/ja.ts +4 -8
  26. package/src/tool/bead-pattern-generator/i18n/ko.ts +4 -8
  27. package/src/tool/bead-pattern-generator/i18n/nl.ts +4 -8
  28. package/src/tool/bead-pattern-generator/i18n/pl.ts +4 -8
  29. package/src/tool/bead-pattern-generator/i18n/pt.ts +4 -8
  30. package/src/tool/bead-pattern-generator/i18n/ru.ts +8 -12
  31. package/src/tool/bead-pattern-generator/i18n/sv.ts +4 -8
  32. package/src/tool/bead-pattern-generator/i18n/tr.ts +4 -8
  33. package/src/tool/bead-pattern-generator/i18n/zh.ts +7 -11
  34. package/src/tool/dice-roller/bibliography.astro +3 -12
  35. package/src/tool/dice-roller/bibliography.ts +5 -0
  36. package/src/tool/dice-roller/entry.ts +0 -1
  37. package/src/tool/dice-roller/i18n/de.ts +12 -16
  38. package/src/tool/dice-roller/i18n/en.ts +12 -16
  39. package/src/tool/dice-roller/i18n/es.ts +11 -15
  40. package/src/tool/dice-roller/i18n/fr.ts +18 -22
  41. package/src/tool/dice-roller/i18n/id.ts +12 -16
  42. package/src/tool/dice-roller/i18n/it.ts +11 -15
  43. package/src/tool/dice-roller/i18n/ja.ts +9 -13
  44. package/src/tool/dice-roller/i18n/ko.ts +10 -14
  45. package/src/tool/dice-roller/i18n/nl.ts +12 -16
  46. package/src/tool/dice-roller/i18n/pl.ts +11 -15
  47. package/src/tool/dice-roller/i18n/pt.ts +12 -16
  48. package/src/tool/dice-roller/i18n/ru.ts +18 -22
  49. package/src/tool/dice-roller/i18n/sv.ts +12 -16
  50. package/src/tool/dice-roller/i18n/tr.ts +10 -14
  51. package/src/tool/dice-roller/i18n/zh.ts +16 -20
  52. package/src/tool/excuse-generator/bibliography.astro +2 -12
  53. package/src/tool/excuse-generator/bibliography.ts +3 -0
  54. package/src/tool/excuse-generator/entry.ts +0 -1
  55. package/src/tool/excuse-generator/i18n/de.ts +7 -9
  56. package/src/tool/excuse-generator/i18n/en.ts +2 -4
  57. package/src/tool/excuse-generator/i18n/es.ts +2 -4
  58. package/src/tool/excuse-generator/i18n/fr.ts +4 -6
  59. package/src/tool/excuse-generator/i18n/id.ts +2 -4
  60. package/src/tool/excuse-generator/i18n/it.ts +2 -4
  61. package/src/tool/excuse-generator/i18n/ja.ts +2 -4
  62. package/src/tool/excuse-generator/i18n/ko.ts +2 -4
  63. package/src/tool/excuse-generator/i18n/nl.ts +2 -4
  64. package/src/tool/excuse-generator/i18n/pl.ts +7 -9
  65. package/src/tool/excuse-generator/i18n/pt.ts +2 -4
  66. package/src/tool/excuse-generator/i18n/ru.ts +7 -9
  67. package/src/tool/excuse-generator/i18n/sv.ts +2 -4
  68. package/src/tool/excuse-generator/i18n/tr.ts +2 -4
  69. package/src/tool/excuse-generator/i18n/zh.ts +9 -11
  70. package/src/tool/fortune-cookie/bibliography.astro +2 -12
  71. package/src/tool/fortune-cookie/bibliography.ts +3 -0
  72. package/src/tool/fortune-cookie/component.astro +7 -10
  73. package/src/tool/fortune-cookie/entry.ts +0 -1
  74. package/src/tool/fortune-cookie/i18n/de.ts +6 -9
  75. package/src/tool/fortune-cookie/i18n/en.ts +6 -9
  76. package/src/tool/fortune-cookie/i18n/es.ts +2 -4
  77. package/src/tool/fortune-cookie/i18n/fr.ts +7 -10
  78. package/src/tool/fortune-cookie/i18n/id.ts +6 -9
  79. package/src/tool/fortune-cookie/i18n/it.ts +3 -6
  80. package/src/tool/fortune-cookie/i18n/ja.ts +2 -5
  81. package/src/tool/fortune-cookie/i18n/ko.ts +2 -5
  82. package/src/tool/fortune-cookie/i18n/nl.ts +6 -9
  83. package/src/tool/fortune-cookie/i18n/pl.ts +6 -9
  84. package/src/tool/fortune-cookie/i18n/pt.ts +6 -9
  85. package/src/tool/fortune-cookie/i18n/ru.ts +17 -20
  86. package/src/tool/fortune-cookie/i18n/sv.ts +6 -9
  87. package/src/tool/fortune-cookie/i18n/tr.ts +5 -8
  88. package/src/tool/fortune-cookie/i18n/zh.ts +7 -10
  89. package/src/tool/glitch-image-distorter/bibliography.astro +8 -0
  90. package/src/tool/glitch-image-distorter/bibliography.ts +4 -0
  91. package/src/tool/glitch-image-distorter/component.astro +393 -0
  92. package/src/tool/glitch-image-distorter/entry.ts +48 -0
  93. package/src/tool/glitch-image-distorter/glitch-image-distorter.css +233 -0
  94. package/src/tool/glitch-image-distorter/i18n/de.ts +131 -0
  95. package/src/tool/glitch-image-distorter/i18n/en.ts +131 -0
  96. package/src/tool/glitch-image-distorter/i18n/es.ts +131 -0
  97. package/src/tool/glitch-image-distorter/i18n/fr.ts +131 -0
  98. package/src/tool/glitch-image-distorter/i18n/id.ts +131 -0
  99. package/src/tool/glitch-image-distorter/i18n/it.ts +131 -0
  100. package/src/tool/glitch-image-distorter/i18n/ja.ts +131 -0
  101. package/src/tool/glitch-image-distorter/i18n/ko.ts +131 -0
  102. package/src/tool/glitch-image-distorter/i18n/nl.ts +131 -0
  103. package/src/tool/glitch-image-distorter/i18n/pl.ts +131 -0
  104. package/src/tool/glitch-image-distorter/i18n/pt.ts +131 -0
  105. package/src/tool/glitch-image-distorter/i18n/ru.ts +131 -0
  106. package/src/tool/glitch-image-distorter/i18n/sv.ts +131 -0
  107. package/src/tool/glitch-image-distorter/i18n/tr.ts +131 -0
  108. package/src/tool/glitch-image-distorter/i18n/zh.ts +131 -0
  109. package/src/tool/glitch-image-distorter/index.ts +9 -0
  110. package/src/tool/glitch-image-distorter/seo.astro +15 -0
  111. package/src/tool/synesthesia-painter/bibliography.astro +3 -12
  112. package/src/tool/synesthesia-painter/bibliography.ts +5 -0
  113. package/src/tool/synesthesia-painter/entry.ts +0 -1
  114. package/src/tool/synesthesia-painter/i18n/de.ts +14 -18
  115. package/src/tool/synesthesia-painter/i18n/en.ts +5 -9
  116. package/src/tool/synesthesia-painter/i18n/es.ts +4 -8
  117. package/src/tool/synesthesia-painter/i18n/fr.ts +10 -14
  118. package/src/tool/synesthesia-painter/i18n/id.ts +5 -9
  119. package/src/tool/synesthesia-painter/i18n/it.ts +2 -6
  120. package/src/tool/synesthesia-painter/i18n/ja.ts +4 -8
  121. package/src/tool/synesthesia-painter/i18n/ko.ts +4 -8
  122. package/src/tool/synesthesia-painter/i18n/nl.ts +4 -8
  123. package/src/tool/synesthesia-painter/i18n/pl.ts +14 -18
  124. package/src/tool/synesthesia-painter/i18n/pt.ts +5 -9
  125. package/src/tool/synesthesia-painter/i18n/ru.ts +17 -21
  126. package/src/tool/synesthesia-painter/i18n/sv.ts +5 -9
  127. package/src/tool/synesthesia-painter/i18n/tr.ts +4 -8
  128. package/src/tool/synesthesia-painter/i18n/zh.ts +15 -19
  129. package/src/tool/zalgo-generator/bibliography.astro +2 -12
  130. package/src/tool/zalgo-generator/bibliography.ts +5 -0
  131. package/src/tool/zalgo-generator/component.astro +1 -1
  132. package/src/tool/zalgo-generator/entry.ts +0 -1
  133. package/src/tool/zalgo-generator/i18n/de.ts +9 -13
  134. package/src/tool/zalgo-generator/i18n/en.ts +5 -9
  135. package/src/tool/zalgo-generator/i18n/es.ts +4 -8
  136. package/src/tool/zalgo-generator/i18n/fr.ts +9 -13
  137. package/src/tool/zalgo-generator/i18n/id.ts +5 -9
  138. package/src/tool/zalgo-generator/i18n/it.ts +4 -8
  139. package/src/tool/zalgo-generator/i18n/ja.ts +4 -8
  140. package/src/tool/zalgo-generator/i18n/ko.ts +4 -8
  141. package/src/tool/zalgo-generator/i18n/nl.ts +5 -9
  142. package/src/tool/zalgo-generator/i18n/pl.ts +9 -13
  143. package/src/tool/zalgo-generator/i18n/pt.ts +5 -9
  144. package/src/tool/zalgo-generator/i18n/ru.ts +9 -13
  145. package/src/tool/zalgo-generator/i18n/sv.ts +5 -9
  146. package/src/tool/zalgo-generator/i18n/tr.ts +5 -9
  147. package/src/tool/zalgo-generator/i18n/zh.ts +9 -13
  148. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-creative",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./entries": "./src/entries.ts",
10
- "./data": "./src/data.ts"
10
+ "./data": "./src/data.ts",
11
+ "./runtime/*": "./src/tool/*/index.ts",
12
+ "./category-seo": "./src/category/seo.astro"
11
13
  },
12
14
  "files": [
13
15
  "src",
@@ -4,6 +4,6 @@ export const content = {
4
4
  description: 'Werkzeuge, die inspirieren, amüsieren und kreativ machen.',
5
5
  seo: [
6
6
  { type: 'title', text: 'Tägliche kreative Hilfsmittel', level: 2 },
7
- { type: 'paragraph', html: 'Von Ausredengeneratoren bis hin zu Bastelmustern unsere Sammlung kreativer Werkzeuge ist für Unterhaltung und praktischen Gebrauch konzipiert.' }
7
+ { type: 'paragraph', html: 'Von Ausredengeneratoren bis hin zu Bastelmustern - unsere Sammlung kreativer Werkzeuge ist für Unterhaltung und praktischen Gebrauch konzipiert.' }
8
8
  ]
9
9
  };
@@ -4,6 +4,6 @@ export const content = {
4
4
  description: 'Инструменты, созданные для того, чтобы вдохновлять, развлекать и создавать.',
5
5
  seo: [
6
6
  { type: 'title', text: 'Ежедневные творческие утилиты', level: 2 },
7
- { type: 'paragraph', html: 'От генераторов оправданий до шаблонов для рукоделия наша коллекция творческих инструментов создана для развлечения и практического использования.' }
7
+ { type: 'paragraph', html: 'От генераторов оправданий до шаблонов для рукоделия - наша коллекция творческих инструментов создана для развлечения и практического использования.' }
8
8
  ]
9
9
  };
package/src/data.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { creativeCategory as _creativeCategory, CREATIVE_TOOLS } from './category';
1
+ import { creativeCategory as _creativeCategory } from './category';
2
+ import { CREATIVE_TOOLS } from './index';
2
3
 
3
4
  export * from './types';
4
5
  export { CREATIVE_TOOLS };
package/src/entries.ts CHANGED
@@ -2,6 +2,7 @@ export { beadPatternGenerator } from './tool/bead-pattern-generator/entry';
2
2
  export { diceRoller } from './tool/dice-roller/entry';
3
3
  export { excuseGenerator } from './tool/excuse-generator/entry';
4
4
  export { fortuneCookie } from './tool/fortune-cookie/entry';
5
+ export { glitchImageDistorter } from './tool/glitch-image-distorter/entry';
5
6
  export { synesthesiaPainter } from './tool/synesthesia-painter/entry';
6
7
  export { zalgoGenerator } from './tool/zalgo-generator/entry';
7
8
  export { creativeCategory } from './category';
@@ -9,6 +10,7 @@ import { beadPatternGenerator } from './tool/bead-pattern-generator/entry';
9
10
  import { diceRoller } from './tool/dice-roller/entry';
10
11
  import { excuseGenerator } from './tool/excuse-generator/entry';
11
12
  import { fortuneCookie } from './tool/fortune-cookie/entry';
13
+ import { glitchImageDistorter } from './tool/glitch-image-distorter/entry';
12
14
  import { synesthesiaPainter } from './tool/synesthesia-painter/entry';
13
15
  import { zalgoGenerator } from './tool/zalgo-generator/entry';
14
- export const ALL_ENTRIES = [beadPatternGenerator, diceRoller, excuseGenerator, fortuneCookie, synesthesiaPainter, zalgoGenerator];
16
+ export const ALL_ENTRIES = [beadPatternGenerator, diceRoller, excuseGenerator, fortuneCookie, glitchImageDistorter, synesthesiaPainter, zalgoGenerator];
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import { creativeCategory } from './category';
2
2
  import type { ToolDefinition } from './types';
3
3
  import { EXCUSE_GENERATOR_TOOL } from './tool/excuse-generator';
4
4
  import { FORTUNE_COOKIE_TOOL } from './tool/fortune-cookie';
5
+ import { GLITCH_IMAGE_DISTORTER_TOOL } from './tool/glitch-image-distorter';
5
6
  import { SYNESTHESIA_PAINTER_TOOL } from './tool/synesthesia-painter';
6
7
  import { ZALGO_GENERATOR_TOOL } from './tool/zalgo-generator';
7
8
  import { BEAD_PATTERN_GENERATOR_TOOL } from './tool/bead-pattern-generator';
@@ -12,6 +13,7 @@ export const templateCategory = creativeCategory;
12
13
  export const CREATIVE_TOOLS: Record<string, ToolDefinition> = {
13
14
  'excuse-generator': EXCUSE_GENERATOR_TOOL,
14
15
  'fortune-cookie': FORTUNE_COOKIE_TOOL,
16
+ 'glitch-image-distorter': GLITCH_IMAGE_DISTORTER_TOOL,
15
17
  'synesthesia-painter': SYNESTHESIA_PAINTER_TOOL,
16
18
  'zalgo-generator': ZALGO_GENERATOR_TOOL,
17
19
  'bead-pattern-generator': BEAD_PATTERN_GENERATOR_TOOL,
@@ -21,6 +23,7 @@ export const ALL_TOOLS = Object.values(CREATIVE_TOOLS);
21
23
 
22
24
  export * from './tool/excuse-generator';
23
25
  export * from './tool/fortune-cookie';
26
+ export * from './tool/glitch-image-distorter';
24
27
  export * from './tool/synesthesia-painter';
25
28
  export * from './tool/zalgo-generator';
26
29
  export * from './tool/bead-pattern-generator';
@@ -10,7 +10,12 @@ interface Props {
10
10
  hasSidebar?: boolean;
11
11
  }
12
12
 
13
- const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Astro.props;
13
+ const {
14
+ title,
15
+ currentLocale = "es",
16
+ localeUrls = {},
17
+ hasSidebar = false,
18
+ } = Astro.props;
14
19
  ---
15
20
 
16
21
  <!doctype html>
@@ -78,6 +83,7 @@ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Ast
78
83
  transition:
79
84
  background-color 0.3s ease,
80
85
  color 0.3s ease;
86
+ font-family: Inter, sans-serif;
81
87
  }
82
88
 
83
89
  main {
@@ -114,4 +120,3 @@ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Ast
114
120
  }
115
121
  }
116
122
  </style>
117
-
@@ -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
+ });
@@ -18,25 +18,13 @@ describe('Locale Completeness Validation', () => {
18
18
  ).toBeTruthy();
19
19
  }
20
20
  });
21
-
22
- it('bibliographyTitle should be defined when bibliography items exist', async () => {
23
- const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
24
- const content = (await loader?.()) as ToolLocaleContent;
25
-
26
- if (content.bibliography.length > 0) {
27
- expect(
28
- content.bibliographyTitle,
29
- `Tool "${tool.entry.id}" locale "${locale}" has ${content.bibliography.length} bibliography items but is missing bibliographyTitle`,
30
- ).toBeTruthy();
31
- }
32
- });
33
21
  });
34
22
  });
35
23
  });
36
24
  });
37
25
 
38
- it('all 6 tools registered', () => {
39
- expect(ALL_TOOLS.length).toBe(6);
26
+ it('all 7 tools registered', () => {
27
+ expect(ALL_TOOLS.length).toBe(7);
40
28
  });
41
29
  });
42
30
 
@@ -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,198 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import type { SEOSection, ToolLocaleContent } from '../types';
6
+
7
+ const srcDir = join(process.cwd(), 'src');
8
+ const toolDir = join(srcDir, 'tool');
9
+ const geometryReads = [
10
+ 'offsetWidth',
11
+ 'offsetHeight',
12
+ 'offsetTop',
13
+ 'offsetLeft',
14
+ 'clientWidth',
15
+ 'clientHeight',
16
+ 'clientTop',
17
+ 'clientLeft',
18
+ 'scrollWidth',
19
+ 'scrollHeight',
20
+ 'scrollTop',
21
+ 'scrollLeft',
22
+ 'getBoundingClientRect',
23
+ 'getClientRects',
24
+ 'computedStyle',
25
+ 'getComputedStyle',
26
+ ];
27
+ const domWrites = [
28
+ '.style.',
29
+ '.classList.add',
30
+ '.classList.remove',
31
+ '.classList.toggle',
32
+ '.appendChild',
33
+ '.insertBefore',
34
+ '.prepend',
35
+ '.append',
36
+ '.remove',
37
+ '.innerHTML',
38
+ '.textContent',
39
+ '.setAttribute',
40
+ ];
41
+
42
+ function findFiles(dir: string, extensions: string[]): string[] {
43
+ const files: string[] = [];
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ const fullPath = join(dir, entry.name);
46
+ if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
47
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
48
+ }
49
+ return files;
50
+ }
51
+
52
+ function relativePath(file: string): string {
53
+ return relative(process.cwd(), file).replace(/\\/g, '/');
54
+ }
55
+
56
+ function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
57
+ return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
58
+ }
59
+
60
+ function attrValue(tag: string, attr: string): string | null {
61
+ const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
62
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
63
+ }
64
+
65
+ function booleanAttr(tag: string, attr: string): boolean {
66
+ return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
67
+ }
68
+
69
+ function controlStartIndex(content: string, tag: RegExpMatchArray): number {
70
+ return tag.index ?? content.indexOf(tag[0]);
71
+ }
72
+
73
+ function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
74
+ const index = controlStartIndex(content, tag);
75
+ const before = content.slice(0, index);
76
+ const labelOpen = before.lastIndexOf('<label');
77
+ const labelClose = before.lastIndexOf('</label>');
78
+ const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
79
+ return labelOpen > labelClose && nextLabelClose !== -1;
80
+ }
81
+
82
+ function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
83
+ const source = tag[0];
84
+ if (attrValue(source, 'aria-label')) return true;
85
+ if (attrValue(source, 'aria-labelledby')) return true;
86
+ const id = attrValue(source, 'id');
87
+ if (id && hasExplicitLabel(content, id)) return true;
88
+ return hasWrappingLabel(content, tag);
89
+ }
90
+
91
+ function isVisuallyHiddenFileInput(tag: string): boolean {
92
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
93
+ const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
94
+ const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
95
+ return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
96
+ }
97
+
98
+ function isIgnoredInput(tag: string): boolean {
99
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
100
+ return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
101
+ }
102
+
103
+ function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
104
+ return findFormControls(content, tagName)
105
+ .filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
106
+ .filter((tag) => !hasAccessibleName(content, tag))
107
+ .map((tag) => tag[0]);
108
+ }
109
+
110
+ function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
111
+ return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
112
+ }
113
+
114
+ function hasExplicitLabel(content: string, id: string): boolean {
115
+ const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ return (
117
+ new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
118
+ || new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
119
+ );
120
+ }
121
+
122
+ function headingLevels(sections: SEOSection[]): number[] {
123
+ return sections
124
+ .filter((section) => section.type === 'title')
125
+ .map((section) => Number('level' in section ? section.level : 0))
126
+ .filter((level) => Number.isInteger(level) && level > 0);
127
+ }
128
+
129
+ function findHeadingLevelJumps(levels: number[]): string[] {
130
+ const failures: string[] = [];
131
+ levels.forEach((level, index) => {
132
+ const previous = index === 0 ? 1 : levels[index - 1];
133
+ if (previous && level > previous + 1) {
134
+ failures.push(`h${previous} -> h${level}`);
135
+ }
136
+ });
137
+ return failures;
138
+ }
139
+
140
+ function hasDomWriteBeforeGeometryRead(content: string): boolean {
141
+ const normalized = content.replace(/\s+/g, ' ');
142
+ return domWrites.some((write) => {
143
+ const writeIndex = normalized.indexOf(write);
144
+ if (writeIndex === -1) return false;
145
+ return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
146
+ });
147
+ }
148
+
149
+ describe('PageSpeed best-practice guards', () => {
150
+ const astroToolFiles = findFiles(toolDir, ['.astro']);
151
+ const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
152
+
153
+ astroToolFiles.forEach((file) => {
154
+ const displayPath = relativePath(file);
155
+
156
+ it(`${displayPath} labels every input with an explicit label`, () => {
157
+ const content = readFileSync(file, 'utf-8');
158
+ const failures = controlFailures(content, 'input');
159
+
160
+ expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
161
+ });
162
+
163
+ it(`${displayPath} labels every select with an explicit label`, () => {
164
+ const content = readFileSync(file, 'utf-8');
165
+ const failures = controlFailures(content, 'select');
166
+
167
+ expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
168
+ });
169
+ });
170
+
171
+ ALL_TOOLS.forEach((tool) => {
172
+ Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
173
+ it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
174
+ if (!loader) return;
175
+ const content = (await loader()) as ToolLocaleContent;
176
+ const levels = headingLevels(content.seo);
177
+ const failures = findHeadingLevelJumps(levels);
178
+
179
+ expect(
180
+ failures,
181
+ `SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
182
+ ).toEqual([]);
183
+ });
184
+ });
185
+ });
186
+
187
+ scriptFiles.forEach((file) => {
188
+ const displayPath = relativePath(file);
189
+
190
+ it(`${displayPath} avoids static forced-reflow patterns`, () => {
191
+ const content = readFileSync(file, 'utf-8');
192
+ expect(
193
+ hasDomWriteBeforeGeometryRead(content),
194
+ `${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
195
+ ).toBe(false);
196
+ });
197
+ });
198
+ });