@jjlmoya/utils-streaming 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-streaming",
3
- "version": "1.12.0",
3
+ "version": "1.13.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
  "./data": "./src/data.ts",
10
- "./entries": "./src/entries.ts"
10
+ "./entries": "./src/entries.ts",
11
+ "./runtime/*": "./src/tool/*/index.ts",
12
+ "./category-seo": "./src/category/seo.astro"
11
13
  },
12
14
  "files": [
13
15
  "src",
@@ -96,7 +96,7 @@ export const content: CategoryLocaleContent = {
96
96
  },
97
97
  {
98
98
  type: 'paragraph',
99
- html: 'Erfolgreiche Creator nutzen Verhaltenspsychologie: Belohnungen nach variablen Quoten, Community-Meilensteine, saisonale Events. Unsere Tools automatisieren die Zufallslogik, sodass Sie sich auf Ihr kreatives Talent konzentrieren können: Unterhalten, Bilden, Verbinden. Die technische Infrastruktur ist gelöst.',
99
+ html: 'Erfolgreiche Creator nutzen Verhaltenspsychologie: Belohnungen nach variablen Quoten, Community-Meilensteine, saisonale Events. Unsere Tools automatisieren die Zufallslogik, sodass Sie sich auf Ihr kreatives Talent konzentrieren können: Unterhalten, Bilden, Verbinden. Die technische \"Infrastruktur\" ist gelöst.',
100
100
  },
101
101
  {
102
102
  type: 'title',
@@ -8,7 +8,7 @@ export const content: CategoryLocaleContent = {
8
8
  seo: [
9
9
  {
10
10
  type: 'title',
11
- text: 'Écosystème de Streaming 2026 : Interaction et Souveraineté Réseau',
11
+ text: 'Écosystème de Streaming 2026: Interaction et Souveraineté Réseau',
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: 'Gamification et Communauté : Tirages au Sort et Concours Transparents',
24
+ text: 'Gamification et Communauté: Tirages au Sort et Concours Transparents',
25
25
  level: 2,
26
26
  },
27
27
  {
@@ -34,7 +34,7 @@ export const content: CategoryLocaleContent = {
34
34
  },
35
35
  {
36
36
  type: 'title',
37
- text: 'Audit Réseau et Neutralité : Tebas-Check',
37
+ text: 'Audit Réseau et Neutralité: Tebas-Check',
38
38
  level: 2,
39
39
  },
40
40
  {
@@ -61,12 +61,12 @@ export const content: CategoryLocaleContent = {
61
61
  },
62
62
  {
63
63
  type: 'title',
64
- text: 'Gestion de Communauté : Modèles de Modération et de Sécurité',
64
+ text: 'Gestion de Communauté: Modèles de Modération et de Sécurité',
65
65
  level: 2,
66
66
  },
67
67
  {
68
68
  type: 'paragraph',
69
- html: 'La communauté est l\'atout le plus précieux d\'un streamer. Cependant, croître sans structure est chaotique. Une communauté de 500 personnes est facile à gérer seul, mais à 5000, vous avez besoin de systèmes, de règles claires et d\'outils de modération. Nos utilitaires incluent des générateurs de tirages au sort qui fonctionnent également comme des systèmes de participation fair-play : chaque utilisateur inscrit a exactement la même probabilité de gagner, sans favoritisme.',
69
+ html: 'La communauté est l\'atout le plus précieux d\'un streamer. Cependant, croître sans structure est chaotique. Une communauté de 500 personnes est facile à gérer seul, mais à 5000, vous avez besoin de systèmes, de règles claires et d\'outils de modération. Nos utilitaires incluent des générateurs de tirages au sort qui fonctionnent également comme des systèmes de participation fair-play: chaque utilisateur inscrit a exactement la même probabilité de gagner, sans favoritisme.',
70
70
  },
71
71
  {
72
72
  type: 'paragraph',
@@ -74,7 +74,7 @@ export const content: CategoryLocaleContent = {
74
74
  },
75
75
  {
76
76
  type: 'title',
77
- text: 'Infrastructure et Résilience : Quand le FAI s\'interpose',
77
+ text: 'Infrastructure et Résilience: Quand le FAI s\'interpose',
78
78
  level: 2,
79
79
  },
80
80
  {
@@ -83,29 +83,29 @@ export const content: CategoryLocaleContent = {
83
83
  },
84
84
  {
85
85
  type: 'paragraph',
86
- html: 'Savoir si votre connexion est auditée ou bloquée vous donne du pouvoir : vous pouvez changer de FAI, utiliser des proxies légaux ou simplifier votre infrastructure en fonction de ce que vous découvrez. Un direct qui souffre de coupures inattendues ou de lag pourrait être dû à des blocages réseau, pas à votre équipement.',
86
+ html: 'Savoir si votre connexion est auditée ou bloquée vous donne du pouvoir: vous pouvez changer de FAI, utiliser des proxies légaux ou simplifier votre infrastructure en fonction de ce que vous découvrez. Un direct qui souffre de coupures inattendues ou de lag pourrait être dû à des blocages réseau, pas à votre équipement.',
87
87
  },
88
88
  {
89
89
  type: 'title',
90
- text: 'Monétisation Durable : Du Direct aux Revenus Stables',
90
+ text: 'Monétisation Durable: Du Direct aux Revenus Stables',
91
91
  level: 2,
92
92
  },
93
93
  {
94
94
  type: 'paragraph',
95
- html: 'Faire du streaming une carrière durable nécessite une diversification. Abonnements, dons, publicité, merchandising, cours : chaque source nécessite une audience engagée. Les outils de gamification comme les tirages au sort ne sont pas seulement du divertissement ; ce sont des mécanismes psychologiques qui augmentent la rétention.',
95
+ html: 'Faire du streaming une carrière durable nécessite une diversification. Abonnements, dons, publicité, merchandising, cours: chaque source nécessite une audience engagée. Les outils de gamification comme les tirages au sort ne sont pas seulement du divertissement ; ce sont des mécanismes psychologiques qui augmentent la rétention.',
96
96
  },
97
97
  {
98
98
  type: 'paragraph',
99
- html: 'Les créateurs qui réussissent utilisent la psychologie comportementale : renforcement à ratio variable, étapes communautaires, événements saisonniers. Nos outils automatisent la logique aléatoire pour que vous puissiez vous concentrer sur votre talent créatif : divertir, éduquer, connecter. La "tuyauterie" technique est résolue.',
99
+ html: 'Les créateurs qui réussissent utilisent la psychologie comportementale: renforcement à ratio variable, étapes communautaires, événements saisonniers. Nos outils automatisent la logique aléatoire pour que vous puissiez vous concentrer sur votre talent créatif: divertir, éduquer, connecter. La "tuyauterie" technique est résolue.',
100
100
  },
101
101
  {
102
102
  type: 'title',
103
- text: 'Omnicanalité : Le Streaming Fragmenté Nécessite des Outils Unifiés',
103
+ text: 'Omnicanalité: Le Streaming Fragmenté Nécessite des Outils Unifiés',
104
104
  level: 2,
105
105
  },
106
106
  {
107
107
  type: 'paragraph',
108
- html: 'Un streamer moderne ne diffuse pas seulement sur Twitch. Il diffuse simultanément sur Twitch, YouTube, Kick, Twitter/X. Cela signifie que votre tirage au sort doit être équitable même si les participants proviennent de plusieurs plateformes. Nos outils sont agnostiques vis-à-vis de la plateforme : vous importez une liste d\'utilisateurs, vous lancez le sélecteur, vous obtenez un gagnant transparent.',
108
+ html: 'Un streamer moderne ne diffuse pas seulement sur Twitch. Il diffuse simultanément sur Twitch, YouTube, Kick, Twitter/X. Cela signifie que votre tirage au sort doit être équitable même si les participants proviennent de plusieurs plateformes. Nos outils sont agnostiques vis-à-vis de la plateforme: vous importez une liste d\'utilisateurs, vous lancez le sélecteur, vous obtenez un gagnant transparent.',
109
109
  },
110
110
  {
111
111
  type: 'paragraph',
@@ -17,7 +17,7 @@ export const content: CategoryLocaleContent = {
17
17
  },
18
18
  {
19
19
  type: 'paragraph',
20
- html: 'Od uczciwego i przejrzystego zarządzania losowaniami na żywo po audyt cenzury sieciowej i blokad stosowanych przez dostawców Internetu nasze narzędzia dają Ci niezbędną kontrolę w konkurencyjnym świecie tworzenia treści na żywo.',
20
+ html: 'Od uczciwego i przejrzystego zarządzania losowaniami na żywo po audyt cenzury sieciowej i blokad stosowanych przez dostawców Internetu - nasze narzędzia dają Ci niezbędną kontrolę w konkurencyjnym świecie tworzenia treści na żywo.',
21
21
  },
22
22
  {
23
23
  type: 'title',
@@ -87,7 +87,7 @@ export const content: CategoryLocaleContent = {
87
87
  },
88
88
  {
89
89
  type: 'title',
90
- text: 'Zrównoważona monetyzacja: Od livea do stabilnego dochodu',
90
+ text: 'Zrównoważona monetyzacja: Od live\'a do stabilnego dochodu',
91
91
  level: 2,
92
92
  },
93
93
  {
@@ -13,7 +13,7 @@ export const content: CategoryLocaleContent = {
13
13
  },
14
14
  {
15
15
  type: 'paragraph',
16
- html: 'Стриминг превратился из простой передачи видео в сложную интерактивную и социальную среду. В этом разделе мы предлагаем <strong>бесплатные онлайн-инструменты</strong>, разработанные для стримеров Twitch, YouTube и Kick, которые стремятся профессионализировать свой контент и защитить свою техническую инфраструктуру. Способность динамизировать ваше сообщество и обеспечивать доступность ваших ресурсов основа успешного канала.',
16
+ html: 'Стриминг превратился из простой передачи видео в сложную интерактивную и социальную среду. В этом разделе мы предлагаем <strong>бесплатные онлайн-инструменты</strong>, разработанные для стримеров Twitch, YouTube и Kick, которые стремятся профессионализировать свой контент и защитить свою техническую инфраструктуру. Способность динамизировать ваше сообщество и обеспечивать доступность ваших ресурсов - основа успешного канала.',
17
17
  },
18
18
  {
19
19
  type: 'paragraph',
@@ -26,7 +26,7 @@ export const content: CategoryLocaleContent = {
26
26
  },
27
27
  {
28
28
  type: 'paragraph',
29
- html: 'Геймификация ключ к удержанию аудитории. Наш <strong>инструмент для розыгрышей и конкурсов</strong> позволяет выбирать случайных победителей полностью прозрачным способом. Благодаря локальной обработке на вашем терминале вы гарантируете отсутствие внешних манипуляций, что способствует доверию подписчиков во время живых розыгрышей.',
29
+ html: 'Геймификация - ключ к удержанию аудитории. Наш <strong>инструмент для розыгрышей и конкурсов</strong> позволяет выбирать случайных победителей полностью прозрачным способом. Благодаря локальной обработке на вашем терминале вы гарантируете отсутствие внешних манипуляций, что способствует доверию подписчиков во время живых розыгрышей.',
30
30
  },
31
31
  {
32
32
  type: 'tool',
@@ -39,7 +39,7 @@ export const content: CategoryLocaleContent = {
39
39
  },
40
40
  {
41
41
  type: 'paragraph',
42
- html: 'В цифровой среде 2026 года судебные блокировки и ограничения провайдеров могут повлиять на стабильность вашего стрима или доступ вашего сообщества к вашим ресурсам. <strong>Tebas-Check</strong> это инструмент технической диагностики, который определяет, применяет ли ваш оператор цензуру или превентивные блокировки на инфраструктурах, таких как Cloudflare или других контент-провайдерах, позволяя вам проводить аудит нейтральности вашей сети.',
42
+ html: 'В цифровой среде 2026 года судебные блокировки и ограничения провайдеров могут повлиять на стабильность вашего стрима или доступ вашего сообщества к вашим ресурсам. <strong>Tebas-Check</strong> - это инструмент технической диагностики, который определяет, применяет ли ваш оператор цензуру или превентивные блокировки на инфраструктурах, таких как Cloudflare или других контент-провайдерах, позволяя вам проводить аудит нейтральности вашей сети.',
43
43
  },
44
44
  {
45
45
  type: 'tool',
@@ -57,7 +57,7 @@ export const content: CategoryLocaleContent = {
57
57
  {
58
58
  type: 'tip',
59
59
  title: 'Профессиональный девиз прямого эфира',
60
- html: '<p><strong>Прозрачность:</strong> при проведении розыгрыша не забудьте захватить экран, на котором запущен инструмент. Демонстрация вашему сообществу процесса случайного выбора в реальном времени лучший способ построить доверительные отношения и долгосрочную лояльность.</p>',
60
+ html: '<p><strong>Прозрачность:</strong> при проведении розыгрыша не забудьте захватить экран, на котором запущен инструмент. Демонстрация вашему сообществу процесса случайного выбора в реальном времени - лучший способ построить доверительные отношения и долгосрочную лояльность.</p>',
61
61
  },
62
62
  {
63
63
  type: 'title',
@@ -66,7 +66,7 @@ export const content: CategoryLocaleContent = {
66
66
  },
67
67
  {
68
68
  type: 'paragraph',
69
- html: 'Сообщество самый ценный актив стримера. Рост без структуры хаотичен. Сообществом из 500 человек можно управлять в одиночку, но при 5000 вам нужны системы, четкие правила и инструменты модерации. Наши утилиты включают генераторы розыгрышей, которые также работают как системы честного участия: каждый зарегистрированный пользователь имеет абсолютно равные шансы на победу.',
69
+ html: 'Сообщество - самый ценный актив стримера. Рост без структуры хаотичен. Сообществом из 500 человек можно управлять в одиночку, но при 5000 вам нужны системы, четкие правила и инструменты модерации. Наши утилиты включают генераторы розыгрышей, которые также работают как системы честного участия: каждый зарегистрированный пользователь имеет абсолютно равные шансы на победу.',
70
70
  },
71
71
  {
72
72
  type: 'paragraph',
@@ -92,7 +92,7 @@ export const content: CategoryLocaleContent = {
92
92
  },
93
93
  {
94
94
  type: 'paragraph',
95
- html: 'Превращение стриминга в устойчивую карьеру требует диверсификации. Подписки, донаты, реклама, мерч, курсы: каждый источник нуждается в вовлеченной аудитории. Инструменты геймификации, такие как розыгрыши, это не просто развлечение; это психологические механизмы, повышающие удержание.',
95
+ html: 'Превращение стриминга в устойчивую карьеру требует диверсификации. Подписки, донаты, реклама, мерч, курсы: каждый источник нуждается в вовлеченной аудитории. Инструменты геймификации, такие как розыгрыши, - это не просто развлечение; это психологические механизмы, повышающие удержание.',
96
96
  },
97
97
  {
98
98
  type: 'paragraph',
@@ -109,7 +109,7 @@ export const content: CategoryLocaleContent = {
109
109
  },
110
110
  {
111
111
  type: 'paragraph',
112
- html: 'Этот омниканальный подход будущее. Фрагментация платформ необратима, и процветают те авторы, которые быстро адаптируются. Наличие набора утилит, работающих на любой платформе, является конкурентным преимуществом.',
112
+ html: 'Этот омниканальный подход - будущее. Фрагментация платформ необратима, и процветают те авторы, которые быстро адаптируются. Наличие набора утилит, работающих на любой платформе, является конкурентным преимуществом.',
113
113
  },
114
114
  {
115
115
  type: 'title',
@@ -96,7 +96,7 @@ export const content: CategoryLocaleContent = {
96
96
  },
97
97
  {
98
98
  type: 'paragraph',
99
- html: '成功的创作者利用行为心理学:可变比例强化、社区里程碑、季节性活动。我们的工具自动化了随机逻辑,因此您可以专注于您的创意天赋:娱乐、教育、连接。技术“管道”问题已解决。',
99
+ html: '成功的创作者利用行为心理学:可变比例强化、社区里程碑、季节性活动。我们的工具自动化了随机逻辑,因此您可以专注于您的创意天赋:娱乐、教育、连接。技术\"管道\"问题已解决。',
100
100
  },
101
101
  {
102
102
  type: 'title',
@@ -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
+ });
@@ -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
+ });