@jjlmoya/utils-health 1.15.0 → 1.16.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 +1 -1
- package/src/category/i18n/fr.ts +2 -2
- package/src/category/i18n/ru.ts +2 -2
- package/src/layouts/PreviewLayout.astro +1 -0
- package/src/tests/diacritics_density.test.ts +118 -0
- package/src/tests/inverted_punctuation.test.ts +84 -0
- package/src/tests/no_en_dash.test.ts +70 -0
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/script_density.test.ts +94 -0
- package/src/tool/binauralTuner/component.astro +6 -6
- package/src/tool/binauralTuner/i18n/de.ts +6 -6
- package/src/tool/binauralTuner/i18n/en.ts +6 -6
- package/src/tool/binauralTuner/i18n/es.ts +5 -5
- package/src/tool/binauralTuner/i18n/fr.ts +6 -6
- package/src/tool/binauralTuner/i18n/id.ts +6 -6
- package/src/tool/binauralTuner/i18n/it.ts +8 -8
- package/src/tool/binauralTuner/i18n/ja.ts +6 -6
- package/src/tool/binauralTuner/i18n/ko.ts +6 -6
- package/src/tool/binauralTuner/i18n/nl.ts +6 -6
- package/src/tool/binauralTuner/i18n/pl.ts +6 -6
- package/src/tool/binauralTuner/i18n/pt.ts +6 -6
- package/src/tool/binauralTuner/i18n/ru.ts +11 -11
- package/src/tool/binauralTuner/i18n/sv.ts +6 -6
- package/src/tool/binauralTuner/i18n/tr.ts +7 -7
- package/src/tool/binauralTuner/i18n/zh.ts +8 -8
- package/src/tool/bloodUnitConverter/bibliography.ts +2 -2
- package/src/tool/bloodUnitConverter/i18n/pl.ts +2 -2
- package/src/tool/bloodUnitConverter/i18n/ru.ts +3 -3
- package/src/tool/bloodUnitConverter/i18n/tr.ts +12 -12
- package/src/tool/bloodUnitConverter/i18n/zh.ts +2 -2
- package/src/tool/bmiCalculator/bibliography.ts +1 -1
- package/src/tool/bmiCalculator/component.astro +2 -2
- package/src/tool/bmiCalculator/i18n/fr.ts +11 -11
- package/src/tool/bmiCalculator/i18n/it.ts +3 -3
- package/src/tool/bmiCalculator/i18n/ru.ts +7 -7
- package/src/tool/bmiCalculator/i18n/tr.ts +10 -10
- package/src/tool/breathingVisualizer/i18n/pl.ts +2 -2
- package/src/tool/breathingVisualizer/i18n/ru.ts +1 -1
- package/src/tool/breathingVisualizer/i18n/tr.ts +3 -3
- package/src/tool/breathingVisualizer/i18n/zh.ts +2 -2
- package/src/tool/caffeineTracker/component.astro +79 -57
- package/src/tool/caffeineTracker/i18n/fr.ts +1 -1
- package/src/tool/caffeineTracker/i18n/pl.ts +3 -3
- package/src/tool/caffeineTracker/i18n/ru.ts +7 -7
- package/src/tool/caffeineTracker/i18n/tr.ts +9 -9
- package/src/tool/caffeineTracker/i18n/zh.ts +5 -5
- package/src/tool/daltonismSimulator/bibliography.ts +1 -1
- package/src/tool/daltonismSimulator/i18n/fr.ts +1 -1
- package/src/tool/daltonismSimulator/i18n/it.ts +4 -4
- package/src/tool/daltonismSimulator/i18n/ru.ts +4 -4
- package/src/tool/daltonismSimulator/i18n/tr.ts +3 -3
- package/src/tool/daltonismSimulator/i18n/zh.ts +1 -1
- package/src/tool/digestionStopwatch/bibliography.ts +1 -1
- package/src/tool/digestionStopwatch/i18n/de.ts +5 -5
- package/src/tool/digestionStopwatch/i18n/en.ts +5 -5
- package/src/tool/digestionStopwatch/i18n/es.ts +5 -5
- package/src/tool/digestionStopwatch/i18n/fr.ts +5 -5
- package/src/tool/digestionStopwatch/i18n/id.ts +5 -5
- package/src/tool/digestionStopwatch/i18n/it.ts +5 -5
- package/src/tool/digestionStopwatch/i18n/ja.ts +1 -1
- package/src/tool/digestionStopwatch/i18n/ko.ts +1 -1
- package/src/tool/digestionStopwatch/i18n/nl.ts +5 -5
- package/src/tool/digestionStopwatch/i18n/pl.ts +6 -6
- package/src/tool/digestionStopwatch/i18n/pt.ts +5 -5
- package/src/tool/digestionStopwatch/i18n/ru.ts +9 -9
- package/src/tool/digestionStopwatch/i18n/sv.ts +5 -5
- package/src/tool/digestionStopwatch/i18n/tr.ts +7 -7
- package/src/tool/digestionStopwatch/i18n/zh.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/de.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/en.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/es.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/fr.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/id.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/it.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/ja.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/ko.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/nl.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/pl.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/pt.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/ru.ts +12 -12
- package/src/tool/epworthSleepinessScale/i18n/sv.ts +4 -4
- package/src/tool/epworthSleepinessScale/i18n/tr.ts +5 -5
- package/src/tool/epworthSleepinessScale/i18n/zh.ts +6 -6
- package/src/tool/hydrationCalculator/bibliography.ts +3 -3
- package/src/tool/hydrationCalculator/component.astro +4 -4
- package/src/tool/hydrationCalculator/i18n/fr.ts +7 -7
- package/src/tool/hydrationCalculator/i18n/ru.ts +3 -3
- package/src/tool/hydrationCalculator/i18n/tr.ts +2 -2
- package/src/tool/hydrationCalculator/i18n/zh.ts +3 -3
- package/src/tool/pelliRobsonTest/component.astro +1 -0
- package/src/tool/pelliRobsonTest/i18n/pl.ts +1 -1
- package/src/tool/pelliRobsonTest/i18n/ru.ts +4 -4
- package/src/tool/pelliRobsonTest/i18n/tr.ts +6 -6
- package/src/tool/peripheralVisionTrainer/component.astro +6 -1
- package/src/tool/peripheralVisionTrainer/i18n/pl.ts +5 -5
- package/src/tool/peripheralVisionTrainer/i18n/ru.ts +8 -8
- package/src/tool/peripheralVisionTrainer/i18n/zh.ts +6 -6
- package/src/tool/readingDistanceCalculator/bibliography.ts +3 -3
- package/src/tool/readingDistanceCalculator/component.astro +1 -0
- package/src/tool/readingDistanceCalculator/i18n/de.ts +2 -2
- package/src/tool/readingDistanceCalculator/i18n/en.ts +1 -1
- package/src/tool/readingDistanceCalculator/i18n/fr.ts +6 -6
- package/src/tool/readingDistanceCalculator/i18n/id.ts +1 -1
- package/src/tool/readingDistanceCalculator/i18n/it.ts +1 -1
- package/src/tool/readingDistanceCalculator/i18n/nl.ts +1 -1
- package/src/tool/readingDistanceCalculator/i18n/pl.ts +1 -1
- package/src/tool/readingDistanceCalculator/i18n/pt.ts +1 -1
- package/src/tool/readingDistanceCalculator/i18n/ru.ts +5 -5
- package/src/tool/readingDistanceCalculator/i18n/sv.ts +2 -2
- package/src/tool/readingDistanceCalculator/i18n/zh.ts +1 -1
- package/src/tool/screenDecompressionTime/bibliography.ts +2 -2
- package/src/tool/screenDecompressionTime/component.astro +1 -1
- package/src/tool/screenDecompressionTime/i18n/de.ts +9 -9
- package/src/tool/screenDecompressionTime/i18n/en.ts +9 -9
- package/src/tool/screenDecompressionTime/i18n/es.ts +1 -1
- package/src/tool/screenDecompressionTime/i18n/fr.ts +2 -2
- package/src/tool/screenDecompressionTime/i18n/id.ts +9 -9
- package/src/tool/screenDecompressionTime/i18n/it.ts +3 -3
- package/src/tool/screenDecompressionTime/i18n/nl.ts +4 -4
- package/src/tool/screenDecompressionTime/i18n/pl.ts +9 -9
- package/src/tool/screenDecompressionTime/i18n/pt.ts +8 -8
- package/src/tool/screenDecompressionTime/i18n/ru.ts +8 -8
- package/src/tool/screenDecompressionTime/i18n/sv.ts +9 -9
- package/src/tool/screenDecompressionTime/i18n/tr.ts +6 -6
- package/src/tool/screenDecompressionTime/i18n/zh.ts +4 -4
- package/src/tool/tinnitusReliever/bibliography.ts +3 -3
- package/src/tool/tinnitusReliever/component.astro +10 -2
- package/src/tool/tinnitusReliever/i18n/de.ts +2 -2
- package/src/tool/tinnitusReliever/i18n/en.ts +2 -2
- package/src/tool/tinnitusReliever/i18n/fr.ts +2 -2
- package/src/tool/tinnitusReliever/i18n/id.ts +2 -2
- package/src/tool/tinnitusReliever/i18n/it.ts +5 -5
- package/src/tool/tinnitusReliever/i18n/nl.ts +2 -2
- package/src/tool/tinnitusReliever/i18n/pl.ts +2 -2
- package/src/tool/tinnitusReliever/i18n/pt.ts +2 -2
- package/src/tool/tinnitusReliever/i18n/ru.ts +6 -6
- package/src/tool/tinnitusReliever/i18n/sv.ts +2 -2
- package/src/tool/tinnitusReliever/i18n/tr.ts +1 -1
- package/src/tool/tinnitusReliever/i18n/zh.ts +4 -4
- package/src/tool/ubeCalculator/i18n/de.ts +2 -2
- package/src/tool/ubeCalculator/i18n/en.ts +2 -2
- package/src/tool/ubeCalculator/i18n/es.ts +1 -1
- package/src/tool/ubeCalculator/i18n/fr.ts +2 -2
- package/src/tool/ubeCalculator/i18n/id.ts +2 -2
- package/src/tool/ubeCalculator/i18n/it.ts +1 -1
- package/src/tool/ubeCalculator/i18n/nl.ts +2 -2
- package/src/tool/ubeCalculator/i18n/pl.ts +4 -4
- package/src/tool/ubeCalculator/i18n/pt.ts +2 -2
- package/src/tool/ubeCalculator/i18n/ru.ts +5 -5
- package/src/tool/ubeCalculator/i18n/sv.ts +4 -4
- package/src/tool/ubeCalculator/i18n/tr.ts +1 -1
- package/src/tool/ubeCalculator/i18n/zh.ts +3 -3
- package/src/tool/waterPurifier/i18n/pl.ts +2 -2
- package/src/tool/waterPurifier/i18n/ru.ts +5 -5
- package/src/tool/waterPurifier/i18n/zh.ts +2 -2
package/package.json
CHANGED
package/src/category/i18n/fr.ts
CHANGED
|
@@ -4,7 +4,7 @@ export const content: CategoryLocaleContent = {
|
|
|
4
4
|
slug: 'sante',
|
|
5
5
|
title: 'Outils de Santé en Ligne',
|
|
6
6
|
description:
|
|
7
|
-
"Calculatrices et utilitaires de santé gratuits
|
|
7
|
+
"Calculatrices et utilitaires de santé gratuits: IMC, pourcentage de graisse, calories, hydratation et plus. Basés sur les normes médicales internationales.",
|
|
8
8
|
seo: [
|
|
9
9
|
{
|
|
10
10
|
type: 'summary',
|
|
@@ -13,7 +13,7 @@ export const content: CategoryLocaleContent = {
|
|
|
13
13
|
"Calculatrice d'IMC avec échelle visuelle et catégories OMS",
|
|
14
14
|
'Résultats basés sur des algorithmes médicaux vérifiés',
|
|
15
15
|
'Totalement gratuit, sans inscription ni cookies de suivi',
|
|
16
|
-
'Confidentialité totale
|
|
16
|
+
'Confidentialité totale: les calculs se font sur votre appareil',
|
|
17
17
|
],
|
|
18
18
|
},
|
|
19
19
|
{
|
package/src/category/i18n/ru.ts
CHANGED
|
@@ -23,7 +23,7 @@ export const content: CategoryLocaleContent = {
|
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
type: 'paragraph',
|
|
26
|
-
html: 'Личное здоровье
|
|
26
|
+
html: 'Личное здоровье - одна из областей, где точная информация имеет реальное значение. Наша коллекция <strong>онлайн-утилит для здоровья</strong> создана для предоставления объективных данных, основанных на стандартах Всемирной организации здравоохранения (ВОЗ) и других международно признанных медицинских институтов.',
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
type: 'paragraph',
|
|
@@ -31,7 +31,7 @@ export const content: CategoryLocaleContent = {
|
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
type: 'title',
|
|
34
|
-
text: 'Почему цифра
|
|
34
|
+
text: 'Почему цифра - это не всё',
|
|
35
35
|
level: 3,
|
|
36
36
|
},
|
|
37
37
|
{
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
type LocaleWithDiacritics = keyof typeof DIACRITIC_RULES;
|
|
5
|
+
|
|
6
|
+
const DIACRITIC_RULES = {
|
|
7
|
+
de: {
|
|
8
|
+
language: 'German',
|
|
9
|
+
expectedCharacters: 'ä ö ü ß',
|
|
10
|
+
characters: /[äöüÄÖÜß]/g,
|
|
11
|
+
minPerThousandLetters: 0.1,
|
|
12
|
+
},
|
|
13
|
+
es: {
|
|
14
|
+
language: 'Spanish',
|
|
15
|
+
expectedCharacters: 'á é í ó ú ü ñ',
|
|
16
|
+
characters: /[áéíóúüñÁÉÍÓÚÜÑ]/g,
|
|
17
|
+
minPerThousandLetters: 0.1,
|
|
18
|
+
},
|
|
19
|
+
fr: {
|
|
20
|
+
language: 'French',
|
|
21
|
+
expectedCharacters: 'à â æ ç é è ê ë î ï ô œ ù û ü ÿ',
|
|
22
|
+
characters: /[àâæçéèêëîïôœùûüÿÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸ]/g,
|
|
23
|
+
minPerThousandLetters: 0.1,
|
|
24
|
+
},
|
|
25
|
+
it: {
|
|
26
|
+
language: 'Italian',
|
|
27
|
+
expectedCharacters: 'à è é ì í î ò ó ù ú',
|
|
28
|
+
characters: /[àèéìíîòóùúÀÈÉÌÍÎÒÓÙÚ]/g,
|
|
29
|
+
minPerThousandLetters: 0.1,
|
|
30
|
+
},
|
|
31
|
+
pl: {
|
|
32
|
+
language: 'Polish',
|
|
33
|
+
expectedCharacters: 'ą ć ę ł ń ó ś ź ż',
|
|
34
|
+
characters: /[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g,
|
|
35
|
+
minPerThousandLetters: 0.1,
|
|
36
|
+
},
|
|
37
|
+
pt: {
|
|
38
|
+
language: 'Portuguese',
|
|
39
|
+
expectedCharacters: 'á â ã à ç é ê í ó ô õ ú ü',
|
|
40
|
+
characters: /[áâãàçéêíóôõúüÁÂÃÀÇÉÊÍÓÔÕÚÜ]/g,
|
|
41
|
+
minPerThousandLetters: 0.1,
|
|
42
|
+
},
|
|
43
|
+
sv: {
|
|
44
|
+
language: 'Swedish',
|
|
45
|
+
expectedCharacters: 'å ä ö',
|
|
46
|
+
characters: /[åäöÅÄÖ]/g,
|
|
47
|
+
minPerThousandLetters: 0.1,
|
|
48
|
+
},
|
|
49
|
+
tr: {
|
|
50
|
+
language: 'Turkish',
|
|
51
|
+
expectedCharacters: 'ç ğ ı İ ö ş ü',
|
|
52
|
+
characters: /[çğıöşüÇĞİÖŞÜ]/g,
|
|
53
|
+
minPerThousandLetters: 0.1,
|
|
54
|
+
},
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
const LETTERS = /\p{L}/gu;
|
|
58
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
59
|
+
|
|
60
|
+
function collectStrings(value: unknown): string[] {
|
|
61
|
+
if (typeof value === 'string') return [value];
|
|
62
|
+
if (!value || typeof value !== 'object') return [];
|
|
63
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
64
|
+
return Object.values(value).flatMap(collectStrings);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeText(value: unknown): string {
|
|
68
|
+
return collectStrings(value).join(' ').normalize('NFC');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function translatableContent(content: Record<string, unknown>) {
|
|
72
|
+
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function letterCount(text: string): number {
|
|
76
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function diacriticCount(text: string, locale: LocaleWithDiacritics): number {
|
|
80
|
+
return text.match(DIACRITIC_RULES[locale].characters)?.length ?? 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function diacriticsPerThousandLetters(text: string, locale: LocaleWithDiacritics): number {
|
|
84
|
+
const letters = letterCount(text);
|
|
85
|
+
if (letters === 0) return 0;
|
|
86
|
+
return diacriticCount(text, locale) / letters * 1000;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('Diacritics density validation', () => {
|
|
90
|
+
ALL_TOOLS.forEach((tool) => {
|
|
91
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
92
|
+
Object.keys(DIACRITIC_RULES).forEach((locale) => {
|
|
93
|
+
it(`${locale} keeps the expected accent and special-letter set`, async () => {
|
|
94
|
+
const typedLocale = locale as LocaleWithDiacritics;
|
|
95
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
96
|
+
if (!loader) return;
|
|
97
|
+
|
|
98
|
+
const content = await loader();
|
|
99
|
+
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
100
|
+
const rule = DIACRITIC_RULES[typedLocale];
|
|
101
|
+
const letters = letterCount(text);
|
|
102
|
+
const matches = diacriticCount(text, typedLocale);
|
|
103
|
+
const density = diacriticsPerThousandLetters(text, typedLocale);
|
|
104
|
+
|
|
105
|
+
expect(
|
|
106
|
+
density,
|
|
107
|
+
[
|
|
108
|
+
`Possible spelling or encoding issue detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
109
|
+
`The text has ${matches} special characters (${density.toFixed(2)} per 1000 letters, ${letters} letters analyzed).`,
|
|
110
|
+
`This locale should include some of these characters: ${rule.expectedCharacters}.`,
|
|
111
|
+
'If the count is 0 or near 0, accents, tildes, or special letters were probably stripped by encoding or normalization.',
|
|
112
|
+
].join(' '),
|
|
113
|
+
).toBeGreaterThanOrEqual(rule.minPerThousandLetters);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
const INVERTED_PUNCTUATION_LOCALES = {
|
|
5
|
+
es: {
|
|
6
|
+
language: 'Spanish',
|
|
7
|
+
questionStart: '¿',
|
|
8
|
+
questionEnd: '?',
|
|
9
|
+
exclamationStart: '¡',
|
|
10
|
+
exclamationEnd: '!',
|
|
11
|
+
},
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
type InvertedPunctuationLocale = keyof typeof INVERTED_PUNCTUATION_LOCALES;
|
|
15
|
+
|
|
16
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
17
|
+
|
|
18
|
+
function collectStrings(value: unknown): string[] {
|
|
19
|
+
if (typeof value === 'string') return [value];
|
|
20
|
+
if (!value || typeof value !== 'object') return [];
|
|
21
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
22
|
+
return Object.values(value).flatMap(collectStrings);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function translatableStrings(content: Record<string, unknown>): string[] {
|
|
26
|
+
return TRANSLATABLE_KEYS.flatMap((key) => collectStrings(content[key]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sentenceStart(text: string, endIndex: number): string {
|
|
30
|
+
const beforeMark = text.slice(0, endIndex).trimEnd();
|
|
31
|
+
const boundary = Math.max(
|
|
32
|
+
beforeMark.lastIndexOf('.'),
|
|
33
|
+
beforeMark.lastIndexOf(':'),
|
|
34
|
+
beforeMark.lastIndexOf(';'),
|
|
35
|
+
beforeMark.lastIndexOf('\n'),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return beforeMark.slice(boundary + 1).trimStart();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findMissingInvertedMarks(
|
|
42
|
+
text: string,
|
|
43
|
+
startMark: string,
|
|
44
|
+
endMark: string,
|
|
45
|
+
): string[] {
|
|
46
|
+
return [...text.matchAll(new RegExp(`\\${endMark}`, 'g'))]
|
|
47
|
+
.map((match) => sentenceStart(text, match.index ?? 0))
|
|
48
|
+
.filter((segment) => segment.length > 0 && !segment.includes(startMark))
|
|
49
|
+
.map((segment) => `${segment}${endMark}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('Inverted punctuation validation', () => {
|
|
53
|
+
ALL_TOOLS.forEach((tool) => {
|
|
54
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
55
|
+
Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
|
|
56
|
+
it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
|
|
57
|
+
const typedLocale = locale as InvertedPunctuationLocale;
|
|
58
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
59
|
+
if (!loader) return;
|
|
60
|
+
|
|
61
|
+
const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
|
|
62
|
+
const content = await loader();
|
|
63
|
+
const strings = translatableStrings(content as Record<string, unknown>);
|
|
64
|
+
const missingQuestions = strings.flatMap((text) =>
|
|
65
|
+
findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
|
|
66
|
+
);
|
|
67
|
+
const missingExclamations = strings.flatMap((text) =>
|
|
68
|
+
findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd)
|
|
69
|
+
);
|
|
70
|
+
const failures = [...missingQuestions, ...missingExclamations];
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
failures,
|
|
74
|
+
[
|
|
75
|
+
`Missing opening punctuation marks in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
76
|
+
`Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
|
|
77
|
+
`Examples: ${failures.slice(0, 5).join(' | ')}`,
|
|
78
|
+
].join(' '),
|
|
79
|
+
).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function getFiles(dir: string): string[] {
|
|
6
|
+
const results: string[] = [];
|
|
7
|
+
if (!fs.existsSync(dir)) {
|
|
8
|
+
return results;
|
|
9
|
+
}
|
|
10
|
+
const list = fs.readdirSync(dir);
|
|
11
|
+
for (const file of list) {
|
|
12
|
+
const fullPath = path.join(dir, file);
|
|
13
|
+
const stat = fs.statSync(fullPath);
|
|
14
|
+
if (stat && stat.isDirectory()) {
|
|
15
|
+
if (file !== 'tests' && file !== 'node_modules' && file !== '.astro') {
|
|
16
|
+
results.push(...getFiles(fullPath));
|
|
17
|
+
}
|
|
18
|
+
} else {
|
|
19
|
+
results.push(fullPath);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return results;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isContentFile(filePath: string): boolean {
|
|
26
|
+
return /\\i18n\\/.test(filePath) || filePath.endsWith('bibliography.ts');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const srcDir = path.join(process.cwd(), 'src');
|
|
30
|
+
const scriptsDir = path.join(process.cwd(), 'scripts');
|
|
31
|
+
const filesToTest = [
|
|
32
|
+
...getFiles(srcDir).filter(isContentFile),
|
|
33
|
+
...getFiles(scriptsDir).filter(isContentFile),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const aiTypographyGarbage = [
|
|
37
|
+
'\u2013',
|
|
38
|
+
'\u2014',
|
|
39
|
+
'\u2026',
|
|
40
|
+
'\u201C',
|
|
41
|
+
'\u201D',
|
|
42
|
+
'\u2018',
|
|
43
|
+
'\u2019',
|
|
44
|
+
'\u00AB',
|
|
45
|
+
'\u00BB',
|
|
46
|
+
'\u200B',
|
|
47
|
+
'\u201E',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
describe('Typography Garbage Character Validation', () => {
|
|
51
|
+
filesToTest.forEach((filePath) => {
|
|
52
|
+
const relativePath = path.relative(process.cwd(), filePath);
|
|
53
|
+
it(`should not contain typography garbage characters in ${relativePath}`, () => {
|
|
54
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
55
|
+
const hasAiPatterns = aiTypographyGarbage.some(char => content.includes(char));
|
|
56
|
+
expect(hasAiPatterns).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it(`should not contain space before colon in ${relativePath}`, () => {
|
|
60
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
61
|
+
const spaceBeforeColon = / : /.test(content);
|
|
62
|
+
expect(spaceBeforeColon).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it(`should not contain double hyphen in ${relativePath}`, () => {
|
|
66
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
67
|
+
expect(content).not.toContain('--');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -111,6 +111,12 @@ const presets = [
|
|
|
111
111
|
let isPlaying = false;
|
|
112
112
|
let isStrobeActive = false;
|
|
113
113
|
|
|
114
|
+
function resizeCanvas() {
|
|
115
|
+
if (!canvas) return;
|
|
116
|
+
canvas.width = canvas.parentElement?.clientWidth ?? 0;
|
|
117
|
+
canvas.height = canvas.parentElement?.clientHeight ?? 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
114
120
|
const WAVE_THRESHOLDS = [4, 8, 14, 30] as const;
|
|
115
121
|
|
|
116
122
|
function getBeatDescription(beat: number): string {
|
|
@@ -231,12 +237,6 @@ const presets = [
|
|
|
231
237
|
updateLabels();
|
|
232
238
|
}
|
|
233
239
|
|
|
234
|
-
function resizeCanvas() {
|
|
235
|
-
if (!canvas) return;
|
|
236
|
-
canvas.width = canvas.parentElement?.clientWidth ?? 0;
|
|
237
|
-
canvas.height = canvas.parentElement?.clientHeight ?? 0;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
240
|
baseInput?.addEventListener('input', updateLabels);
|
|
241
241
|
beatInput?.addEventListener('input', updateLabels);
|
|
242
242
|
toggleBtn?.addEventListener('click', toggleAudio);
|
|
@@ -136,7 +136,7 @@ export const content: ToolLocaleContent<BinauralTunerUI> = {
|
|
|
136
136
|
{ type: 'title', text: 'Luzide Träume und Binaural-Beats', level: 2 },
|
|
137
137
|
{
|
|
138
138
|
type: 'paragraph',
|
|
139
|
-
html: 'Das Phänomen des <strong>luziden Träumens</strong>
|
|
139
|
+
html: 'Das Phänomen des <strong>luziden Träumens</strong> - jener Zustand, in dem sich der Träumende voll bewusst ist, dass er träumt - fasziniert die Menschheit seit Jahrhunderten. Von tibetischen "Traum-Yoga"-Praktiken bis hin zur modernen Forschung von Dr. Stephen LaBerge an der Stanford University ist die Fähigkeit, die Traumumgebung zu kontrollieren, ein mächtiges Werkzeug für tiefe psychologische Erkundung.',
|
|
140
140
|
},
|
|
141
141
|
{
|
|
142
142
|
type: 'paragraph',
|
|
@@ -147,11 +147,11 @@ export const content: ToolLocaleContent<BinauralTunerUI> = {
|
|
|
147
147
|
type: 'table',
|
|
148
148
|
headers: ['Zustand', 'Bereich (Hz)', 'Haupteffekt'],
|
|
149
149
|
rows: [
|
|
150
|
-
['<strong>Delta</strong>', '0,5
|
|
151
|
-
['<strong>Theta</strong>', '4
|
|
152
|
-
['<strong>Alpha</strong>', '8
|
|
153
|
-
['<strong>Beta</strong>', '14
|
|
154
|
-
['<strong>Gamma</strong>', '30
|
|
150
|
+
['<strong>Delta</strong>', '0,5 - 4 Hz', 'Traumloser Tiefschlaf, Zellregeneration und körperliche Heilung.'],
|
|
151
|
+
['<strong>Theta</strong>', '4 - 8 Hz', 'Hypnagoger Zustand, luzides Träumen, tiefe Meditation und Gedächtnis.'],
|
|
152
|
+
['<strong>Alpha</strong>', '8 - 14 Hz', 'Bewusste Entspannung, Visualisierung und Stressabbau.'],
|
|
153
|
+
['<strong>Beta</strong>', '14 - 30 Hz', 'Logisches Denken, Problemlösung und äußere Aufmerksamkeit.'],
|
|
154
|
+
['<strong>Gamma</strong>', '30 - 50 Hz', 'Kognitive Verarbeitung auf hohem Niveau und "Heureka"-Momente.'],
|
|
155
155
|
],
|
|
156
156
|
},
|
|
157
157
|
{ type: 'title', text: 'Wie man luzide Träume mit Sound induziert', level: 2 },
|